Repository navigation
fix(ssl): refuse a TLS handshake when the listener supervisor is backlogged - #238
Merged
zmstone merged 1 commit intoNov 6, 2026
Merged
Conversation
zmstone
force-pushed
the
261105-refuse-tls-handshake-when-backlogged
branch
2 times, most recently
from
November 5, 2026 15:49
4f50b4c to
8054f68
Compare
zmstone
marked this pull request as ready for review
November 5, 2026 16:15
…logged A TLS connect storm can wedge the OTP ssl listener supervisor for hours. ssl:handshake/3 starts the connection processes with supervisor:start_child(tls_connection_sup, ...), and each start scans that supervisor's whole mailbox, because proc_lib:sync_start/2 waits in a selective receive the compiler cannot mark: two of its clauses match on a pid instead of the monitor reference. The cost of one start therefore grows with the backlog, and the total cost grows with its square. Measured on a node with 300,000 queued requests: 38.7 starts per second and 12M reductions per second, nearly all of it mailbox scanning. The queue length held at exactly 300,000, because each served request left one EXIT message behind it. Callers wait in gen_server:call with an infinity timeout, so the listener handshake_timeout does not bound the start, nothing times out and no client connects. Refuse the handshake while the supervisor mailbox, which is the number of handshakes waiting to start, is at or above esockd's max_pending_handshakes environment value. The default is 100 and infinity disables the check. A short backlog keeps each start cheap, which keeps the backlog short. The default of 100 comes from a measurement on a 20 core host with OTP 28.4.1 and TLS 1.2, using 16 acceptors and performing the handshake in a separate process for each accepted socket, as esockd does. One start_child costs about 350 microseconds of supervisor time, so the single process ceiling is near 3,000 starts per second. The mailbox peaked between 16 and 75 messages for 8 to 128 concurrent connects, and only reached 157 at 256 concurrent connects, where throughput had already collapsed because the host was saturated. Throughput does not rise with concurrency beyond that point, so refusing above 100, about 35 ms of queued supervisor work, discards backlog rather than rate. The refusal reason is the bare atom too_many_handshakes, so esockd_connection_sup counts it under its own shutdown key rather than grouping it with real TLS errors. Verified on a 300,000 connection test: the supervisor mailbox stayed at 0, 161,440 connections established and 138,560 were refused.
zmstone
force-pushed
the
261105-refuse-tls-handshake-when-backlogged
branch
from
November 5, 2026 16:17
8054f68 to
0ce6ad8
Compare
thalesmg
approved these changes
Nov 5, 2026
Member
Author
|
better option: |
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
A TLS connect storm can wedge the OTP ssl listener supervisor for hours.
ssl:handshake/3starts the connection processes withsupervisor:start_child(tls_connection_sup, ...), and each start scans thatsupervisor's whole mailbox, because
proc_lib:sync_start/2waits in a selectivereceive the compiler cannot mark. The cost of one start grows with the backlog,
so a large enough backlog never clears. Callers wait in
gen_server:callwithan infinity timeout, so nothing times out and nothing recovers.
Measured on a node that had taken 300,000 TLS connections:
tls_connection_supmailbox (messages)Reductions per start matched the mailbox length, so nearly all of the CPU was
mailbox scanning.
Change
Refuse the handshake while the supervisor mailbox, which is the number of
handshakes waiting to start, is at or above
max_pending_handshakes. A shortbacklog keeps each start cheap.
max_pending_handshakesis an esockd application environment value, declared inesockd.app.srcnext tosocket_fast_close_methodand read the same way. It isa node-wide setting rather than a per-listener option. The default is 100,
infinitydisables the check, and0refuses everything, which the tests use.The refusal reason is the bare atom
too_many_handshakes, soesockd_connection_supcounts it under its own shutdown key rather thangrouping it with real TLS errors.
The check returns false when
tls_connection_supis not registered, so aDTLS-only listener and a node without the
sslapplication behave as before.Choosing the default
Measured on a 20 core host with OTP 28.4.1 and TLS 1.2, 3,000 connections per
run. The server uses a fixed pool of 16 acceptors, each handing the accepted
socket to its own process for the handshake, which is how esockd drives it, so
in-flight server handshakes are not bounded by the acceptor count. Client and
server share the node, so each connection costs two
start_childcalls and themailbox holds requests from both sides.
One run per row, so read the rate column as roughly 1,200 with noise.
Peak throughput of about 1,500 connections per second is 3,000
start_childcalls per second, so roughly 350 microseconds of supervisor time for one call.
The supervisor is a single process, which makes that a node-wide ceiling.
The queue stays below 100 across the whole range where throughput holds up. It
only reaches 100 at 256 concurrent connects, where the rate has already
collapsed to 390 per second because the host is saturated, and even then for
0.06 percent of the samples. So a limit of 100, about 35 ms of queued
supervisor work, does not cost throughput. It discards backlog that was not
going to raise the rate.
Scope
This targets 5.18.x only. Older lines are out of scope, because a listener that
keeps its connection rate limit reaches that limit before the supervisor
backlog can build.
Verification
On a 300,000 connection test with this change, the supervisor mailbox stayed at
0, 161,440 connections were established and 138,560 were refused.
make xref,make dialyzer,make eunitandmake ctpass on OTP 28.4.1. CTis 246 passed and 4 skipped, the skips being pre-existing environment ones. Four
new cases cover the declared default,
infinity, the refusal, and an idlesupervisor.