Keep TLS session resumption alive across config reloads - #42
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements TLS session resumption across configuration reloads by threading a long-lived TlsConfigCache through the route table build process, ensuring that unchanged certificates retain their ServerConfig allocations (and thus their session caches and ticket keys). It also introduces a mark-and-sweep mechanism to retire rotated-out certificates and adds comprehensive tests. The review feedback correctly identifies a potential issue where aborted or failed configuration builds can leave stale keys in the cache's used set, leading to temporary memory leaks on subsequent successful reloads. It is recommended to implement the suggested clear_used method and invoke it at the start of each route table build to prevent this.
08c421f to
aff6593
Compare
symphony already configures rustls for session resumption (a session cache for TLS 1.2 IDs, a ticketer for TLS 1.3 tickets), and it works — but only until the first config reload. Both of those live on the rustls ServerConfig, and build_route_table created a TlsConfigCache per call. Every route-table rebuild therefore minted a brand-new ServerConfig for every route, including ones whose cert had not changed at all, which threw away the session cache and generated fresh random ticket keys. Every ticket already handed to a client became undecryptable. Nothing errors — clients just quietly go back to full handshakes. That matters because reloads are routine: adding or removing a route, or an on-disk cert renewal picked up by the file watcher, rebuilds the whole table. Measured against the current build: a TLS 1.3 client resumes on its second connection, then fails to resume after an updateConfig() that changes nothing. Fix: the proxy owns the TlsConfigCache and threads it through every build_route_table, so an unchanged cert maps to the same Arc<ServerConfig> and keeps its session state. Keying on the cert bytes gives the right lifetime for free — sessions live as long as the cert they were issued under, and a rotation retires them. Two details worth keeping: - The sweep (retain_used, mark-and-sweep over keys touched during a build) is the caller's and runs at the commit point in proxy.rs, never inside build_route_table. updateConfig is all-or-nothing: a route build can succeed and then be discarded when the protection half fails validation, and sweeping against a table that never went live would retire configs the still-running table is serving from. Over-retaining for one generation is the safe direction. - Ticket keys stay per-config, not process-global. A shared ticketer would let a ticket minted under one tenant's cert resume against another tenant's route. Tests: unit tests for config identity across rebuilds (with the per-build cache as an explicit control) and for the sweep; an end-to-end spec covering TLS 1.2 and 1.3 resumption, survival across a reload, and correct non-resumption across a cert rotation. The reload assertions fail on the previous behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
aff6593 to
6348b46
Compare
Devin-Holland
left a comment
There was a problem hiding this comment.
Reviewed at head 6348b46. Built the addon, ran both suites, and probed the sweep rather than reading it — which turned up one real gap.
-
should-fix (confirmed empirically) — the sweep retires a config the committed table is still serving from. The carry-forward branch at
router.rs:424(prev.clone(), the mid-rotation cert/key mismatch path) reuses the previous table'sArc<ServerConfig>without going throughget_or_build, so its cache key is never marked, andretain_used()then drops it.I added a temporary test at this head to confirm: after a mismatch carries the route forward,
cache.len()goes to 0 while the live table still serves that exact config — so there is no immediate breakage, and clients keep resuming. The cost lands on the next rebuild: when the mismatch heals back to the same cert bytes,get_or_buildmisses and mints a differentServerConfig(I printed the pointers:0x125805f90vs0x125804db0) — fresh ticketer, so every outstanding ticket for that tenant dies. That is exactly the failure this PR removes, reappearing through the one path the carry-forward logic exists to protect.Worth noting the
proxy.rscomment states the intended invariant — "retire the ServerConfigs that no route in the new table asked for" — and a carried-forward route is in the new table. The mark is keyed on build participation rather than table membership. Nothing covers this: the e2e spec exercises reload and rotation, never a mid-rotation mismatch.I validated a one-line fix in a worktree — it makes the probe pass and keeps the existing eviction test green (full lib suite 123/123 with the probe added):
self.cache.retain(|k, v| used.contains(k) || Arc::strong_count(v) > 1);
The cache holds one strong reference, so
> 1means a live table (or an in-flight connection) still holds it. The trade-off is over-retention: a rotated-out cert's config lingers while the old table or a long-lived connection references it — the direction this PR already declares safe, and bounded by connection lifetime rather than unbounded. The more explicit alternative is storing theCacheKeyonRouteso the carry-forward branch can re-mark directly;Routecurrently keeps only theArc(router.rs:167). -
note, latent — not reachable today — the build and the commit-point sweep are not one critical section, and the mark set is shared. Two interleaved
update_configcalls (A builds, B builds, B commits and sweeps clearingused, A commits and sweeps against an empty mark set) would empty the entire cache. Not reachable now:update_configis a synchronous#[napi]method (proxy.rs:561-562), so JS cannot interleave two of them. Worth a clause in the comment beside the sweep, because it becomes reachable the moment that signature goes async — and thestrong_countguard above happens to defuse it as well.
Verified (ran it, not read it):
- Rust suite at this head: 122 pass / 0 fail, including both new tests.
unchanged_cert_keeps_its_server_config_across_rebuildsearns its keep — the explicit per-build-cache control is what makes it state the bug rather than merely assert current behavior. - Built the debug addon and ran the new e2e spec. All three subtests green across TLS 1.3 and 1.2: a session is issued and resumes, it survives an
updateConfig()that leaves the cert alone, and it correctly does not resume across a rotation. The spec is wired intonpm test, so it will not rot unrun. - The sweep-at-the-commit-point argument holds.
update_configbuilds the route table and can still bail on protection validation before the swap, so sweeping insidebuild_route_tablereally would retire configs the still-live table is serving from.drop(cache)before validation and re-locking after the swap is the right shape. - Per-config ticketer rather than process-global is right for tenant isolation. Routes sharing identical cert bytes do share a config and therefore ticket keys — that is the same TLS identity, and routing stays SNI-based off the ClientHello, so no cross-tenant reachability follows from it.
- Lock handling is deliberately asymmetric and correct — the build path turns a poisoned mutex into an error, the commit path uses
if let Okand skips the sweep, which over-retains (safe) rather than dropping live state. - No CI regression from the new API surface.
cargo clippy --all-targets, which is what CI runs and it does not deny warnings, reports 12 warnings on both this head andorigin/main; the newlen/is_emptyonly look dead in a lib-only build.
Verdict: comment.
— Claude & DevAIn (Claude Opus 5)
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Co-Authored-By: GPT-5 Codex <noreply@openai.com>
Devin-Holland
left a comment
There was a problem hiding this comment.
Re-reviewed at head 4205895d. Both findings from the last round are fixed. I probed each half by reverting it rather than by reading the new tests, and both new tests fail without their fix — they earn their keep.
Round-1 findings: both closed
-
The sweep retired a carried-forward config — fixed by the
Arc::strong_count(config) > 1clause inretain_used. Reverting just that clause turnscarried_forward_config_survives_sweepred (123 pass / 1 fail), so the test pins the actual bug rather than merely asserting current behaviour. -
Gemini's stale-marks leak — fixed by
clear_used()at the top ofbuild_route_table, in a stronger form than the suggested one. Reducingclear_usedto the suggestedself.used.clear()alone turnsaborted_build_marks_do_not_retain_configsred, as does removing the call entirely. Worth stating why the stronger form matters:retain_usednever runs on the abort path, so back-to-back failing reloads — a persistently-invalid protection config retried by the reconcile loop — would add one config per attempt with no upper bound. The extraretaininsideclear_usedis what bounds that, and the test's first assertion is the one that catches it.
Verified (ran it, not read it)
- Lib suite at this head: 124 pass / 0 fail.
npm test: 124 pass / 0 fail across 43 suites, including the session-resumption spec. - The unmarked-
Arcsurface is closed.get_or_buildhas exactly one caller (build_route,router.rs:540), and listener-level TLS only supplies fallback cert bytes — it never holds aServerConfigof its own. So a live route acquires its config in exactly two ways:get_or_build(marked) and the carry-forwardprev.clone()(unmarked, now covered by refcount). There is no third path to miss. - Over-retention is bounded by connection lifetime, not unbounded.
proxy_conn.rs:188clones the route'sArc<ServerConfig>into per-connection state, so a rotated-out config lingers only as long as the longest connection that used it, then goes on the next sweep. - The interleaving note from last round is now defused, not merely unreachable. A live table's configs always have
strong_count >= 2, so even two interleavedupdate_configcalls trampling the shared mark set could not evict live state.
Blocking, but mechanical: this doesn't merge
main is 6 commits ahead (route-scoped proxy metrics + duplicate-route isolation) and touched build_route_table on the same lines. I resolved it to check for a semantic conflict, and there isn't one — it's two hunks plus five call sites:
router.rs, thebuild_routecall — takemain'slet mut routewith this PR'scache(no longer a local):let mut route = match build_route(spec, listener_tls, cache) {router.rs,transient_failure_retains_last_good_on_hot_swap— keepmain'smetric_identityassertions, add the 4th argument.- Five
build_route_table(...)calls inmain's new metrics tests need&mut TlsConfigCache::new().
Merged result: 130/130 Rust, 131/131 JS, both new tests among them.
Design note — no action needed
The mark set is now redundant in production. Because swap() precedes the sweep, a committed table's configs always have strong_count >= 2, so used never decides anything. I checked by deleting used entirely and evicting purely on refcount: 124/124 still pass, the only edit needed being to bind the table in sweep_retires_configs_no_route_references (it currently discards it — the one thing production never does).
I am not suggesting you change it: used is the more explicit contract, and pure refcounting over-retains silently if anything ever stashes an Arc<ServerConfig> outside a route. But it does mean the refcount clause, not the mark set, is what makes this correct now — and nothing in the file says so. Hence the one inline comment below.
Verdict: approve. Both inline notes are comment-only; the rebase is the only thing to do before merge.
— Claude & DevAIn (Claude Opus 5)
| /// Drop every entry not requested since the previous sweep, retiring rotated-out certs. | ||
| /// Callers run this once they *commit* the table they just built — see `build_route_table`. | ||
| pub fn retain_used(&mut self) { | ||
| let used = std::mem::take(&mut self.used); | ||
| self.cache.retain(|k, config| used.contains(k) || Arc::strong_count(config) > 1); | ||
| } | ||
|
|
||
| /// Discard marks and cache-only configs from a route table that was never committed. | ||
| pub(crate) fn clear_used(&mut self) { | ||
| self.used.clear(); | ||
| self.cache.retain(|_, config| Arc::strong_count(config) > 1); | ||
| } |
There was a problem hiding this comment.
strong_count is the half that fixes the carried-forward bug, and it currently reads as redundant next to the explicit mark set — a future cleanup could delete it and silently reintroduce the fresh-ticketer regression. Same for the retain in clear_used, which is what bounds repeatedly-failing reloads.
| /// Drop every entry not requested since the previous sweep, retiring rotated-out certs. | |
| /// Callers run this once they *commit* the table they just built — see `build_route_table`. | |
| pub fn retain_used(&mut self) { | |
| let used = std::mem::take(&mut self.used); | |
| self.cache.retain(|k, config| used.contains(k) || Arc::strong_count(config) > 1); | |
| } | |
| /// Discard marks and cache-only configs from a route table that was never committed. | |
| pub(crate) fn clear_used(&mut self) { | |
| self.used.clear(); | |
| self.cache.retain(|_, config| Arc::strong_count(config) > 1); | |
| } | |
| /// Drop every entry not requested since the previous sweep, retiring rotated-out certs. | |
| /// Callers run this once they *commit* the table they just built — see `build_route_table`. | |
| /// | |
| /// `strong_count` is load-bearing, not defensive: the carry-forward branch in | |
| /// `build_route_table` reuses the previous table's `Arc` without going through `get_or_build`, | |
| /// so a mid-rotation route is live but unmarked. | |
| pub fn retain_used(&mut self) { | |
| let used = std::mem::take(&mut self.used); | |
| self.cache.retain(|k, config| used.contains(k) || Arc::strong_count(config) > 1); | |
| } | |
| /// Discard marks and cache-only configs from a route table that was never committed. | |
| /// `strong_count == 1` means only the cache holds it, so no live table can lose one here; | |
| /// without the retain, repeatedly-failing reloads accumulate a config per attempt. | |
| pub(crate) fn clear_used(&mut self) { | |
| self.used.clear(); | |
| self.cache.retain(|_, config| Arc::strong_count(config) > 1); | |
| } |
| /// `cache` is the proxy's long-lived `TlsConfigCache`, deliberately *not* created here: an | ||
| /// unchanged cert must map to the same `Arc<ServerConfig>` across rebuilds so its TLS session | ||
| /// state survives the reload. Sweeping it (`retain_used`) is the *caller's* job, once it commits | ||
| /// the returned table — this table is only one half of an all-or-nothing `updateConfig`, and | ||
| /// retiring configs for a table that never goes live would strand the running table's sessions. |
There was a problem hiding this comment.
This paragraph now understates the function: it says sweeping is the caller's job, but the build calls clear_used, which does evict. The invariant still holds — clear_used can only drop entries no live table references — but a reader stopping here would conclude the build never touches cache lifetime.
| /// `cache` is the proxy's long-lived `TlsConfigCache`, deliberately *not* created here: an | |
| /// unchanged cert must map to the same `Arc<ServerConfig>` across rebuilds so its TLS session | |
| /// state survives the reload. Sweeping it (`retain_used`) is the *caller's* job, once it commits | |
| /// the returned table — this table is only one half of an all-or-nothing `updateConfig`, and | |
| /// retiring configs for a table that never goes live would strand the running table's sessions. | |
| /// `cache` is the proxy's long-lived `TlsConfigCache`, deliberately *not* created here: an | |
| /// unchanged cert must map to the same `Arc<ServerConfig>` across rebuilds so its TLS session | |
| /// state survives the reload. Sweeping it (`retain_used`) is the *caller's* job, once it commits | |
| /// the returned table — this table is only one half of an all-or-nothing `updateConfig`, and | |
| /// retiring configs for a table that never goes live would strand the running table's sessions. | |
| /// The `clear_used` below is not that sweep: it drops only configs nothing references yet, i.e. | |
| /// the previous build's, if that update was abandoned. |
Human-Review-Need: 4 @ 4205895
What this is
symphony already supports TLS session resumption, and it works — but only until the first config reload. This makes it survive one.
The problem
src/tls.rssets both halves of resumption on every terminatingServerConfig(rustls defaults to neither):session_storage = ServerSessionMemoryCache::new(1024)— TLS 1.2 session IDsticketer = ring::Ticketer::new()— TLS 1.3 ticketsBoth of those live on the
ServerConfig, andbuild_route_tablecreated its ownTlsConfigCacheper call. So every route-table rebuild minted a brand-newServerConfigfor every route — including routes whose cert had not changed at all — which discarded the session cache and generated fresh random ticket keys. Every ticket already handed to a client became undecryptable.Nothing errors when this happens. Clients just quietly go back to full handshakes.
That matters because reloads are routine: adding or removing a route, or an on-disk cert renewal picked up by the file watcher, rebuilds the whole table. In a multi-tenant deployment, resumption rarely survived long enough to do anything.
Measured against
mainwith a Node client, identical routes and an identical cert on both sides of the reload:The fix
The proxy owns the
TlsConfigCacheand threads it through everybuild_route_table, so an unchanged cert maps to the sameArc<ServerConfig>and keeps its session state across hot-swaps. Keying on the cert bytes gives the right lifetime for free: session state lives exactly as long as the cert it was issued under, and rotating a cert retires it.Two details are load-bearing and worth a close look:
retain_used()(mark-and-sweep over the keys touched during a build) runs inproxy.rsafter the new table is swapped in — never insidebuild_route_table.updateConfigis all-or-nothing: a route build can succeed and then be discarded when the protection half fails validation, and sweeping against a table that never went live would retire configs the still-running table is serving from. Over-retaining for one generation is the safe direction; under-retaining costs live session state.Not addressed (both documented in
CLAUDE.md)Ticketerrotates its keys on its own ~6h schedule, independent of reloads.Both degrade to a full handshake, never to an error.
Tests
router.rs—unchanged_cert_keeps_its_server_config_across_rebuildsasserts config identity across a rebuild, and carries the old per-build cache as an explicit control so the test states what the bug was.sweep_retires_configs_no_route_referencescovers the eviction side.__test__/session-resumption.spec.ts— end-to-end over both TLS 1.2 and 1.3: a session is issued and resumes, it survives anupdateConfig()that doesn't change the cert, and it correctly does not resume across a cert rotation.I confirmed the reload assertions fail on the previous behaviour before keeping them — reverting just the shared-cache change turns those two green tests red and leaves the rest passing.
Full suite: 110/110 JS, 107/107 Rust, clippy clean.
Note on the diff:
cargo fmtandprettier --writereformat this repo wholesale (it isn't formatter-clean today), so I reverted that churn and hand-matched the surrounding style. The diff is only the change.🤖 Generated with Claude Code