Skip to content

Pool TLS 1.3 session tickets per host instead of keeping one - #134313

Open
wfurt wants to merge 1 commit into
dotnet:mainfrom
wfurt:tls13-resume-concurrent
Open

wfurt wants to merge 1 commit into
dotnet:mainfrom
wfurt:tls13-resume-concurrent

Conversation

@wfurt

@wfurt wfurt commented Sep 20, 2026 •

Copy link
Copy Markdown
Member

On Linux the client TLS session cache kept exactly one SSL_SESSION per SNI host. For TLS 1.3 that is wrong in two compounding ways. We can get unnecessary tuen when server sends more tickets and we also have problem with concurrency as we cannot use the same ticket twice for TLS 1.3. The cache design was done for TLS 1.2 where using same ticket over and over again is fine. Also we have possible lock ordering problem.

Concurrent connections TLS 1.2 TLS 1.3 run 1 run 2 run 3 TLS 1.3 mean
1 100% 100% 100% 100% 100%
2 100% 100% 100% 100% 100%
4 100% 100% 100% 100% 100%
8 100% 100% 100% 100% 100%
16 100% 96.9% 92.2% 95.3% 94.8%
32 100% 97.7% 95.3% 88.3% 93.8%
64 100% 84.0% 93.8% 91.4% 89.7%

Measured with the concurrent handshake benchmark from dotnet/performance#5314 over loopback sockets, TLS 1.3 with resumption: 14% faster at 64 concurrent connections and 12% at 128. TLS 1.2 and the non-resuming cases are unchanged; low concurrency costs 2-3%.

Note that this really depends on machine and timing. The problem is nearly invisible when using Memory stream and everything is super fast. But that is not the real world scenario.

This PR separates the behavior so we can have multiple (up to 8) tickets so we have better chance of resumption during parallel processing. We would hold eactly one ticket for cases when site is visited once and never again after e.g. crawlers.

Effect of the fix

Measured with SslStreamConcurrencyTests.ConcurrentHandshake from
dotnet/performance#5314, which performs concurrent TLS handshakes over loopback
sockets. Both runtimes were driven by BenchmarkDotNet's CoreRun toolchain in a
single run on the same machine, with the unmodified build as the baseline, so
the ratios are directly comparable. Linux x64, OpenSSL 3.0.13, RSA-2048 server
certificate. Ratio below 1.00 means the fix is faster.

TLS 1.3 with resumption enabled

Concurrent connections before after ratio
1 4345.7 µs 4495.7 µs 1.03 ± 0.02
8 320.0 µs 326.1 µs 1.02 ± 0.01
64 318.4 µs 273.2 µs 0.86 ± 0.03
128 326.1 µs 285.6 µs 0.88 ± 0.05

14% faster at 64 concurrent connections and 12% at 128.

Cases the change should not affect

Arm N=1 N=8 N=64 N=128
TLS 1.2, resumption enabled 0.99 1.00 0.99 1.01
TLS 1.2, resumption disabled 0.98 0.96 0.97 1.01
TLS 1.3, resumption disabled 0.98 1.00 0.98 0.97

Cost

At low concurrency TLS 1.3 resumption is 2–3% slower (1.03 at N=1, 1.02 at
N=8) and allocates about 1% more (12.44 KB vs 12.33 KB at N=64). This is the
extra SSL_SESSION_up_ref call and the list lookup replacing a dictionary
lookup.

Eviction and cache cleanup
OpenSSL continues to enforce its own global cap of DefaultTlsCacheSizeClient
sessions across all hostnames, unchanged by this PR. When the cache is full
SSL_CTX_add_session evicts from ctx->session_cache_tail until it is back
under the limit, and since SSL_SESSION_list_add keeps the list ordered by
effective expiry rather than by insertion or use, the victim is always the
session nearest to expiring — effectively oldest-first, so idle hosts shed
entries before active ones. Every removal path invokes remove_session_cb,
which is how the managed dictionary stays in sync and how its size stays
transitively bounded by that same cap: the callback finds the entry by the
hostname stashed on the session and by pointer identity, then drops exactly one
reference. OpenSSL raises it even when the session was not in its own hash, so
the removal is guarded to release once per cached reference.

On Linux the client TLS session cache kept exactly one SSL_SESSION per SNI
host. For TLS 1.3 that is wrong in two compounding ways.

An OpenSSL server sends two NewSessionTickets after an initial handshake, so
new_session_cb fires twice. The old TryAddSession handled the second by
evicting the first and calling SSL_CTX_remove_session on it, and
remove_session_lock sets not_resumable = 1 unconditionally. The discarded
ticket was therefore invalidated, not merely dropped, on every connection.

Concurrent connections then contend for the single survivor. SSL_set_session
shares the pointer rather than copying, so when the first of several
concurrent handshakes completes, tls_finish_handshake removes that shared
session and marks it not_resumable; every other connection holding it falls
back to a full handshake.

Cache TLS 1.3 tickets in a small per-host pool so concurrent handshakes can
each take a distinct one. TLS 1.2 still keeps a single entry, which is both
sufficient and all OpenSSL ever provides, since it skips new_session_cb on
resumed TLS 1.2 handshakes.

Add a CryptoNative_SslSessionUpRef shim so TrySetSession can take a reference
under the managed lock, release the lock, and only then call SSL_set_session.
RemoveSession frees outside that lock, so relying on the lock alone to keep
the session alive across the call was unsound.

Measured with the concurrent handshake benchmark from dotnet/performance#5314
over loopback sockets, TLS 1.3 with resumption: 14% faster at 64 concurrent
connections and 12% at 128. TLS 1.2 and the non-resuming cases are unchanged;
low concurrency costs 2-3%.
@wfurt wfurt added this to the 12.0.0 milestone Sep 20, 2026
Copilot AI lite review requested due to automatic review settings September 20, 2026 19:51
@wfurt wfurt added the os-linux Linux OS (any supported distro) label Sep 20, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to this area: @dotnet/ncl, @bartonjs, @vcsjones
See info in area-owners.md if you want to be subscribed.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot review overview

🟡 Changes recommended

Add focused TLS 1.3 pool tests and update the stale cache invariant comment.

Get a fresh assessment by requesting another Copilot review.

Review effort: Lite
Findings: 1 Medium severity

Open (1)
What changed in this PR

Updates Unix OpenSSL TLS session caching to pool multiple TLS 1.3 tickets per host, improving concurrent resumption while preserving TLS 1.2 behavior.

Changes:

  • Adds native and managed SSL_SESSION_up_ref interop.
  • Implements bounded per-host session pools with eviction.
  • Detects negotiated TLS versions when caching sessions.
File Summary
src/​native/​libs/​System.Security.Cryptography.Native/​pal_ssl.h Declares session reference incrementing.
src/​native/​libs/​System.Security.Cryptography.Native/​pal_ssl.c Implements the native wrapper.
src/​native/​libs/​System.Security.Cryptography.Native/​opensslshim.h Registers the OpenSSL symbol.
src/​native/​libs/​System.Security.Cryptography.Native/​entrypoints.c Exports the native entry point.
src/​libraries/​Common/​src/​Interop/​Unix/​System.Security.Cryptography.Native/​Interop.SslCtx.cs Manages pooled sessions, eviction, and references. Moderate (3 votes): add focused concurrency and eviction coverage. Nit (1 vote): update the stale single-session cache comment.
src/​libraries/​Common/​src/​Interop/​Unix/​System.Security.Cryptography.Native/​Interop.Ssl.cs Adds managed interop bindings.
src/​libraries/​Common/​src/​Interop/​Unix/​System.Security.Cryptography.Native/​Interop.OpenSsl.cs Classifies sessions by negotiated TLS version.

}

internal unsafe bool TryAddSession(byte* namePtr, IntPtr session)
internal unsafe bool TryAddSession(byte* namePtr, IntPtr session, bool isTls13)

@rzikm rzikm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM

Comment on lines +226 to 230
if (!_sslSessions.TryGetValue(targetName, out List<CachedSession>? sessions))
{
if (!_sslSessions.TryAdd(targetName, session))
{
// session to this target host exists, replace it
_sslSessions.Remove(targetName, out oldSession);
bool added = _sslSessions.TryAdd(targetName, session);
Debug.Assert(added);
}
sessions = new List<CachedSession>();
_sslSessions[targetName] = sessions;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Net.Security os-linux Linux OS (any supported distro)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants