Stop a stuck client reconnect from blocking every new connection - #798
Conversation
etserver stops accepting connections entirely while a single client is
reconnecting over a dead network path. `et host` hangs with no matching
server-side log line, the kernel accept queue fills, and service only comes
back when the stuck clients are killed. Seen on 7.0.0 with four abandoned
clients on one laptop: the listening socket sat with 27 connections queued
against a backlog of 32, all from the same source address, while the server
log went quiet.
The accept loop and the reconnect path contend for the same server-wide lock.
TerminalServer::run() calls acceptNewConnection() on its own thread, and that
takes classMutex to enqueue the new descriptor. A returning client is handled
on a pool thread, which took the same lock around the recovery:
lock_guard<std::recursive_mutex> guard(classMutex);
serverClientState->recoverClient(clientSocketFd);
recoverClient() calls Connection::recover(), which writes a SequenceHeader,
reads the client's reply, exchanges catchup buffers, and only then returns.
Every one of those four socket operations blocks: reads are bounded by a 30s
idle and 60s absolute deadline, writes by 30s, so a single recovery against a
client that is no longer there can hold classMutex for minutes. Nothing can be
accepted in the meantime, and neither can the router connection that registers
a new session's key, because run() handles both on the same thread.
ClientConnection::pollReconnect() retries once a second and only gives up on
INVALID_KEY, so a few abandoned clients keep the eight-thread pool busy and the
accept queue permanently full.
Take the recovery out of classMutex. The lock was doing two jobs: keeping the
connection alive for the duration of the call, and serializing concurrent
reconnects for the same client. The first is already covered, since
serverClientState is a shared_ptr copy taken under the lock. The second moves
to the connection's own mutex: recoverClient() now holds connectionMutex across
the whole socket swap rather than just the first half, so two reconnects for
one client still cannot interleave, while reconnects for different clients stop
blocking each other and the accept loop. connectionMutex is recursive, so
recover() re-locking it is fine.
removeClient() and destroyPartialConnection() had the mirror-image problem.
They called Connection::shutdown() while holding classMutex, and shutdown()
waits on connectionMutex, which a reconnect can hold across that same blocking
I/O. Both now drop the entry from the maps under classMutex and shut the
connection down after releasing it.
Finally, recover() returns early when the connection is already shutting down.
With the global lock gone, a session can be torn down while a reconnect for it
is in flight, and without the check recover() would revive the reader and
writer of a connection that nobody owns any more.
Testing:
Adds test/unit_tests/AcceptStarvationTest.cpp. It registers a client, opens a
second connection for the same client whose peer never answers the sequence
header -- leaving recover() blocked exactly where a dead path leaves it -- and
then requires acceptNewConnection() to complete for an unrelated descriptor.
Pre-fix the accept is still blocked when the five second deadline expires;
post-fix it returns in under a millisecond. Teardown unblocks the stuck worker,
so a regression fails in five seconds instead of hanging the suite.
ctest is green, including the existing recoverClient coverage in
SecurityNoticesTest, and the ServerConnection tests report no races under
ThreadSanitizer.
TcpSocketHandler::listen() hardcoded a backlog of 32. That queue is what the accept loop drains, so it bounds how many clients can be mid-connect at once; once it is full the kernel stops completing handshakes and `et host` hangs with nothing written to the server log. Thirty-two is small for a daemon whose clients tend to reconnect all at once, after a network partition or when a laptop wakes up, and there was no way to raise it short of rebuilding. Default to 128 and allow an override through `backlog` in the [Networking] section of et.cfg. Non-positive values fall back to the default instead of being handed to listen(), where their meaning is implementation defined. A deeper queue does not fix an accept loop that has stopped draining it, it only buys time. It does stop an ordinary reconnect burst from reaching the cliff in the first place. Testing: Adds test/unit_tests/TcpSocketHandlerTest.cpp covering the default, an explicit value, the non-positive fallback, and a real listen() with a non-default backlog on a loopback ephemeral port.
TerminalServer::handleConnection() waited forever for the first packet:
while (!serverClientState->readPacket(&packet)) {
LOG(INFO) << "Waiting for initial packet...";
sleep(1);
}
A client that finishes the handshake and then goes away leaves that thread
spinning for the life of the process, emitting a line of log every second. On
a server hit by a reconnect storm one such thread had been looping for over
forty minutes, and its messages dominated the log.
The loop also has no exit for shutdown. readPacket() returns false once the
connection is shutting down, so the thread keeps looping while run() blocks
joining it and etserver never exits cleanly.
Give up when the connection is shutting down, or after ten minutes. Little is
lost either way: the initial payload is what starts the terminal, so a client
that never sends one has no session to preserve, and a client whose path
recovers before the deadline still reaches the same thread through the normal
reconnect. Cleanup matches the end of runTerminal(). The surviving log line is
throttled to one in ten.
Testing:
Adds test/integration_tests/HandleConnectionTimeoutTest.cpp, which calls
handleConnection() with an already shut down connection and requires it to
return. Pre-fix it never does. The worker is detached on failure so a
regression is reported instead of hanging the suite.
The wait added in the previous commit checked the connection's own shutdown flag, but that flag is not what stops this server. TerminalServer::shutdown() only sets `halt`, and the unqualified shutdown() in run() resolves to it rather than ServerConnection::shutdown(), so nothing ever marks these connections as shutting down. A client that connected without sending an initial payload therefore kept its thread looping, and run() blocked joining it for up to the ten minute deadline. Check `halt` under terminalThreadMutex, the way the session loops in runJumpHost() and runTerminal() already do, so the thread leaves within a second of shutdown. The flag is read into a local and the lock released before removeClient() takes classMutex, so the existing classMutex -> terminalThreadMutex order stays the only one in play. Testing: Adds a second case to HandleConnectionTimeoutTest for a client whose socket is open and silent -- the state that used to loop forever -- requiring handleConnection() to return once the server is halted. It fails on the parent commit and passes here. That case needs a connection the server does not own: UnixSocketHandler refuses descriptors it did not create, so a socketpair is passed through a raw descriptor handler, and set non-blocking as initSocket() leaves every real socket. A blocking descriptor parks BackedReader::read() while it holds the connection mutex, which is also why teardown only shuts the connection down on the passing path.
listen() has never called freeaddrinfo() on the servinfo it obtains at
TcpSocketHandler.cpp:175, neither on its normal return nor on the bind failure
that throws. connect() frees it on all three of its exits, so this is an
oversight rather than a convention.
Nothing caught it because no test constructed a TcpSocketHandler and called
listen() in process until this branch added one, and LeakSanitizer -- which the
Linux asan job enables by default -- is unavailable on macOS. The new backlog
test turned the leak into a red CI leg:
Direct leak of 64 byte(s) in 1 object(s) allocated from:
MisterTea#1 getaddrinfo
MisterTea#3 et::TcpSocketHandler::listen(et::SocketEndpoint const&)
TcpSocketHandler.cpp:175
SUMMARY: AddressSanitizer: 64 byte(s) leaked in 1 allocation(s).
Catch reported the test itself as passing; only the process exit code changed,
which is why ctest logged a failure with no visible reason.
Also logs the backlog listen() actually applied and warns when a configured
value is discarded, so an operator can tell what took effect, and notes in
et.cfg that Linux truncates the backlog to net.core.somaxconn -- easy to trip
over when raising it.
Testing:
Ubuntu 24.04 with -DSANITIZE_ADDRESS=ON: 161/161. Reverting only this file's
freeaddrinfo calls flips the backlog test's exit code from 0 to 1 with the
report above, so the cause is verified rather than inferred.
The deadline was the point of an earlier commit and the one exit still uncovered: INITIAL_PAYLOAD_TIMEOUT_DURATION is a compile-time constant, so a test could only have reached it by waiting ten minutes. Read it through a protected member that a test subclass can lower. Production behaviour is unchanged, since the member is initialized from the same constant. Testing: Adds a third case to HandleConnectionTimeoutTest: a silent client, a one second deadline, and neither the halt flag nor the connection's shutdown flag set, so only the deadline can end the wait. The socketpair setup the two silent-client cases share moves into a helper. The fixture now stops listening on both pipes it opened. Nothing else did: ServerConnection and UserTerminalRouter each listen in their constructor, and TerminalServer::shutdown() only sets the halt flag, so every case was leaving two listening sockets behind. It also checks mkdtemp's return before building a string from it, since the emptiness check it replaces could never fire.
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## master #798 +/- ##
==========================================
+ Coverage 87.14% 87.45% +0.31%
==========================================
Files 75 78 +3
Lines 6462 6680 +218
Branches 610 621 +11
==========================================
+ Hits 5631 5842 +211
- Misses 831 838 +7 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Taking the recovery out of classMutex frees the accept loop, but the recovery
still occupies the handler thread it runs on, and reconnects for one client
serialize on that connection's mutex. Overlapping attempts therefore each park a
worker for the length of every attempt ahead of them, and that pool is eight
threads wide and shared with every other client's handshake.
Measured against a real etserver: one live session, four silent reconnects
arriving every fifteen seconds for two minutes, and a fresh client attempting to
connect five times through that window.
unpatched hung 45s hung 45s 13s 0s 0s
accept fix 0s 0s 42s 1s 13s
this commit 0s 0s 0s 0s 1s
A real client cannot overlap its own attempts, because pollReconnect() blocks in
its own half of the same handshake before it retries. An unauthenticated peer
can: a reconnect needs nothing but a client id, as MisterTea#784 describes, so a handful
of sockets that answer nothing is enough to hold the pool.
Refusing costs nothing that waiting was buying. The refused socket is closed
before the live session is touched, so the victim keeps its own connection, and a
client that really is reconnecting comes back on its next attempt.
Testing:
A second case in AcceptStarvationTest wedges a recovery, then requires a second
reconnect for the same client to return within five seconds. Without this commit
it waits for the first one's socket timeout and the case fails on that deadline.
It also checks the refused peer sees RETURNING_CLIENT and then EOF, and that the
live session is still registered afterwards. The shared setup the two cases need
moved into a helper.
macOS: 162/162.
|
Three adjacent things I left out of this PR on purpose. Happy to add any of them here or send them separately -- whichever you prefer.
One question beyond this PR: is a |
|
Thanks for a great pr! |
Symptom
etserver stops accepting connections entirely while a single client is reconnecting over a dead network path.
et hosthangs, no matching line ever appears in the server log,sshto the same host is unaffected, and service only comes back when the stuck clients are killed.Observed on 7.0.0 (Ubuntu, port 2022).
ss -tlnshowed the listening socket carrying 22-27 queued connections against a backlog of 32, all from a single source address. That address turned out to be one laptop behind NAT running fouretclients abandoned days earlier, each still retrying.Mechanism
TerminalServer::run()does everything on one thread. The sameselect()loop accepts TCP clients and accepts the router connection that registers a new session's key:acceptNewConnection()takesclassMutexto enqueue the new descriptor. A returning client is handled on one of the eight pool threads, which took the same lock around the recovery:recoverClient()callsConnection::recover(), which writes aSequenceHeader, reads the client's reply, exchanges catchup buffers, and only then returns. All four of those socket operations block. Reads are bounded bySOCKET_IDLE_TIMEOUT_SEC(30s idle) andSOCKET_ABSOLUTE_TIMEOUT_SEC(60s absolute), checked every iteration. Writes are bounded only by the 30s idle timer, whichwriteAllOrThrow()resets on every partial write, so a peer that accepts a trickle holds a write open indefinitely.So one recovery against a client that is no longer reachable holds
classMutexfor as long as its timeouts allow, and for that entire time nothing can be accepted -- not TCP clients, and not the router connection either, sincerun()handles both on the same thread.ClientConnection::pollReconnect()keeps trying: apart from its own shutdown it gives up only when the server answersINVALID_KEY. With several such clients the stalls chain back to back, the accept queue fills, and further clients never complete a handshake.The same lock ordering appears inverted in
removeClient()anddestroyPartialConnection(), which calledConnection::shutdown()while holdingclassMutex;shutdown()waits onconnectionMutex, which a reconnect holds across that same blocking I/O.Changes
Seven commits, independently reviewable:
Stop a stuck reconnect from blocking every new connection. Takes the recovery out of
classMutex. The lock was providing two things: keeping the connection alive across the call, and serializing concurrent reconnects for one client. The first is already covered, sinceserverClientStateis ashared_ptrcopy taken under the lock. The second moves to the connection's own mutex --recoverClient()now holdsconnectionMutexacross the whole socket swap rather than just its first half, so two reconnects for one client still cannot interleave, while reconnects for different clients stop blocking each other and the accept loop.connectionMutexis recursive, sorecover()re-locking it is fine. Holding it for the whole call also closes two pre-existing windows, where another thread could observesocketFd == -1with no recovery in progress, or where ashutdown()slipping between a failedrecover()and the restore would let the restore revive a torn-down connection.removeClient()anddestroyPartialConnection()drop their map entry underclassMutexand shut the connection down after releasing it.recover()returns early when the connection is already shutting down, since with the global lock gone a session can be torn down while a reconnect for it is in flight.Make the listen backlog configurable.
listen()hardcoded 32, which is small for a daemon whose clients tend to reconnect all at once. Defaults to 128, overridable throughbacklogin the[Networking]section ofet.cfg.Bound the wait for a client's initial payload.
handleConnection()looped forever onreadPacket(), one log line per second. On the affected server one such thread had been logging for over forty minutes. Now gives up when the connection is shutting down or after ten minutes; the initial payload is what starts the terminal, so a client that never sends one has no session to preserve, and a client whose path recovers before the deadline still reaches the same thread through the normal reconnect. Note that giving up erases the client key, so a client returning after the deadline getsINVALID_KEYand stops retrying.Exit that wait when the server is halted. The connection's own shutdown flag is not what stops this server:
TerminalServer::shutdown()only setshalt, and it hidesServerConnection::shutdown(), so the unqualified call inrun()resolves to the derived one and nothing ever marks these connections as shutting down. Without ahaltcheck the thread kept looping andrun()blocked joining it until the deadline. CheckshaltunderterminalThreadMutex, the way the session loops inrunJumpHost()andrunTerminal()already do, reading it into a local so the lock is released beforeremoveClient()takesclassMutex.Free the address info in
TcpSocketHandler::listen(). A pre-existing leak of thegetaddrinfo()result, on both the normal return and the bind failure that throws, whileconnect()frees it on every exit. It went unnoticed because nothing calledlisten()in process until this branch added a test, and LeakSanitizer is unavailable on macOS. Also logs the backlog that was actually applied and warns when a configured value is discarded.Test the initial-payload deadline directly. The deadline is read through a protected member so a test subclass can lower it, instead of being unreachable behind a ten minute constant.
Refuse a reconnect while one is in flight for that client. Freeing
classMutexleaves the recovery occupying the handler thread it runs on, and reconnects for one client serialize on that connection's mutex, so overlapping attempts each park a worker behind the ones ahead of them. A real client cannot overlap its own attempts, sincepollReconnect()blocks in its own half of the same handshake before retrying, but an unauthenticated peer can: a reconnect needs nothing but a client id. The refused socket is closed before the live session is touched, and a genuine client returns on its next attempt.Testing
test/unit_tests/AcceptStarvationTest.cppregisters a client, opens a second connection for the same client whose peer never answers the sequence header -- leavingrecover()blocked exactly where a dead path leaves it -- and then requiresacceptNewConnection()to complete for an unrelated descriptor. Pre-fix the accept is still blocked when the five second deadline expires; post-fix it returns in under a millisecond. Waiting for the sequence header rather than theConnectResponseis what makes it deterministic: pre-fix, the recovery'sclassMutexwas taken after that response was written. A second case requires a further reconnect for the same client to be refused within five seconds instead of queueing behind the wedged one.test/integration_tests/HandleConnectionTimeoutTest.cppcovers all three exits: a connection already shutting down, an open but silent client once the server is halted, and the same client against a one second deadline with neither flag set.test/unit_tests/TcpSocketHandlerTest.cppcovers the backlog default, an explicit value, and the non-positive fallback. Itslisten()case only checks that a custom backlog is accepted -- the kernel queue depth is not portably readable.Every regression test was confirmed to fail on the commit that introduced it and pass after, on macOS and on Linux. None of them hangs the suite when it fails: the starvation tests unblock their stuck worker in teardown, and the timeout tests detach their worker and skip the teardown that would wait on a held mutex.
-DDISABLE_VCPKG=ON -DDISABLE_TELEMETRY=ON: 162/162.-DSANITIZE_ADDRESS=ON(what thelinux_ciasan leg runs): 162/162. Reverting only commit 5 flips the backlog test's exit code from 0 to 1 with agetaddrinfoleak report, so that diagnosis is verified rather than inferred.recoverClientcoverage inSecurityNoticesTest. One unrelated abort at process exit inSocketHandler helpers read/write encoded payloadsreproduces identically on the parent commit.End to end
The unit tests pin the mechanism; this checks the reported symptom against real binaries. On Ubuntu 24.04 with sshd, a real
etserver,etterminalandet: one live session, then reconnects for that session which complete the TCP handshake, readRETURNING_CLIENTand the server's sequence header, and then answer nothing -- the state a dead path produces, driven from a script so the timing is deterministic. Meanwhile a client that has never connected before attempts a session five times, at four second intervals, through a two minute window in which four such reconnects arrive every fifteen seconds.Unpatched, the first two attempts never got a session and were killed at the 45 second timeout, which is the reported symptom. The 13 second and 42 second figures are recoveries timing out and releasing what the next attempt was waiting on. With the incident's own shape -- four separate abandoned clients rather than repeated attempts at one session -- commits 1-6 alone already answer every attempt in under a second.
Not addressed here
A recovery still occupies its handler thread for the length of its timeouts, so enough distinct clients recovering at once can still fill an eight thread pool. Commit 7 caps it at one thread per client, which is what makes the measured case behave; removing the exposure entirely would mean not running recovery on a pool thread at all.
ServerConnection::shutdown()still callsConnection::shutdown()and drains the handler pool while holdingclassMutex, so a worker waiting onclassMutexwould never be joined. It is left alone becauseTerminalServer::shutdown()hides it, as commit 4 describes, so the base version is only reached from tests --AcceptStarvationTestavoids the hazard only because its extra client fails its handshake before touchingclassMutex.PipeSocketHandler::listen()still hardcodes a backlog of 5 for the router socket, drained by the same loop this change protects.Reconnect authentication remains as #784 left it, needing a
PROTOCOL_VERSIONbump.