macOS: client deadlocks permanently on TLS reconnect — SecureSocket::freeSSL() holds ssl_mutex_ across SocketMultiplexer::removeSocket()
Deskflow version info
Deskflow: 1.26.0.0
Qt: 6.10.1
System: macOS 26.5.2 (25F84), Apple Silicon (Mac16,10)Build types
- Official release (installer/package from GitHub releases)
Deskflow configuration
- Windows server, macOS 26.5.2 client (Mac mini, Apple Silicon)
- Client runs headless as a LaunchAgent (
org.deskflow.client,KeepAlive=true) - Single monitor per machine, TLS enabled (TLSv1.3)
What steps will reproduce the problem?
- Connect a macOS client to a server over TLS and leave it connected.
- Break the TLS connection in a way that surfaces as
tls error occurred (system call failure)— in my case it happened on its own after ~3 hours of idle connection. - The client logs
disconnected from server, thenconnecting to <server>:24800, and then stops — no success, no error, no retry, forever.
Because the process stays alive, launchd's KeepAlive never fires, so the client sits disconnected until it is manually restarted. Mine stayed wedged for 32 minutes before I noticed; the server was healthy the whole time (ICMP fine, port 24800 accepting connections).
Observed log, with nothing at all after the last line:
[2026-09-07T22:31:20.744] ERROR: tls error occurred (system call failure)
[2026-09-07T22:31:20.749] IPC: disconnected from server
[2026-09-07T22:31:21.759] IPC: connecting to '<server>': <server>:24800The hang is a lock-order deadlock between the event thread and the multiplexer service thread
sample(1) of the wedged process, trimmed to the two relevant threads:
Thread_528084 (Deskflow event queue)
App::runEventsLoop(void const*) + 24
EventQueue::loop() + 344
EventQueue::dispatchEvent(Event const&) + 332
Client::handleConnectTimeout() + 168
Client::cleanupConnection() + 260
PacketStreamFilter::~PacketStreamFilter() + 92
StreamFilter::~StreamFilter() + 104
SecureSocket::~SecureSocket() + 48
SecureSocket::freeSSL() + 44
SocketMultiplexer::removeSocket(ISocket*) + 76
ArchMultithreadPosix::waitCondVar(...) + 204
_pthread_cond_wait + 980
__psynch_cvwait + 8 <-- waiting for the service thread
Thread_528082 (SocketMultiplexer service thread)
SocketMultiplexer::serviceThread(void const*) + 1252
SecureSocket::serviceConnect(ISocketMultiplexerJob*, bool, bool, bool) + 56
SecureSocket::secureConnect(int) + 84
SecureSocket::loadCertificate(QString const&) + 40
_pthread_mutex_firstfit_lock_wait + 84
__psynch_mutexwait + 8 <-- waiting for ssl_mutex_Root cause
Both sides are still present on master (a930c87ea4ec):
void SecureSocket::freeSSL()
{
std::scoped_lock ssl_lock{ssl_mutex_}; // (1) acquire ssl_mutex_
isFatal(true);
// take socket from multiplexer ASAP otherwise the race condition
// could cause events to get called on a dead object. TCPSocket
// will do this, too, but the double-call is harmless
setJob(nullptr); // (2) blocks until the service thread is idle
...
}bool SecureSocket::loadCertificate(const QString &filename)
{
std::scoped_lock ssl_lock{ssl_mutex_}; // runs on the service thread
...
}The two lock acquisitions are ordered inconsistently:
- The event thread takes
ssl_mutex_and then waits, viasetJob(nullptr)→SocketMultiplexer::removeSocket()→waitCondVar, for the service thread to leave the job. - The service thread is inside
serviceConnect()→secureConnect()→loadCertificate(), which wantsssl_mutex_— held by the event thread.
Neither can make progress. The comment above setJob(nullptr) says to remove the socket from the multiplexer "ASAP", but doing that while holding ssl_mutex_ is precisely what closes the cycle.
The trigger is the connect-timeout path: Client::handleConnectTimeout() tears the socket down concurrently with a connect attempt that is still inside the TLS handshake — so a reconnect that is slow enough to hit the timeout while secureConnect() is running deadlocks the client.
Suggested fix
Take the socket out of the multiplexer before acquiring ssl_mutex_, so the mutex is never held across a wait on the service thread:
void SecureSocket::freeSSL()
{
// Detach from the multiplexer first: removeSocket() waits for the service
// thread, which may itself be blocked on ssl_mutex_ inside loadCertificate().
isFatal(true);
setJob(nullptr);
std::scoped_lock ssl_lock{ssl_mutex_};
if (m_ssl) {
...
}
}isFatal(true) / setJob(nullptr) do not touch m_ssl, so hoisting them out of the critical section looks safe, but the maintainers will know whether isFatal() has ordering requirements I cannot see from outside.
A defensive second measure would be to make Client::handleConnectTimeout() not destroy a socket that is still mid-handshake, or to give removeSocket() a bounded wait so a missed edge degrades into a reconnect rather than a permanent hang.
Additional information
- Severity is higher than a crash, from an operator's point of view: a crash is recovered automatically by
KeepAlivein a second or two, whereas this hang leaves a live-but-useless process that nothing restarts. - Reachability was verified while wedged —
ping0% loss, andnc -z <server> 24800succeeded — so the server was accepting connections the entire time. - Related but distinct: #9903 is a hang when quitting/restarting from the GUI; this one is on the reconnect path with no GUI involved.
- Workaround in place here: an external watchdog that restarts
org.deskflow.clientwhen the server's port is reachable but the client holds no ESTABLISHED connection to it.
Source: deskflow/deskflow