#10137·deskflow

macOS: client deadlocks permanently on TLS reconnect — SecureSocket::freeSSL() holds ssl_mutex_ across SocketMultiplexer::removeSocket()

Author: ismailislerrCreated Sep 7, 2026Updated Sep 12, 2026

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?

  1. Connect a macOS client to a server over TLS and leave it connected.
  2. 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.
  3. The client logs disconnected from server, then connecting 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>:24800

The 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):

cpp
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
  ...
}
cpp
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, via setJob(nullptr)SocketMultiplexer::removeSocket()waitCondVar, for the service thread to leave the job.
  • The service thread is inside serviceConnect()secureConnect()loadCertificate(), which wants ssl_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:

cpp
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 KeepAlive in a second or two, whereas this hang leaves a live-but-useless process that nothing restarts.
  • Reachability was verified while wedged — ping 0% loss, and nc -z <server> 24800 succeeded — 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.client when the server's port is reachable but the client holds no ESTABLISHED connection to it.