Skip to content

Audit: non-atomic booleans and ints shared across threads (follow-up to #3786) #3798

Description

@mcfnord

Background. In #3786 the discussion converged on two points: CSocketThread::bRun is written by one thread and read by another, so it should be std::atomic<bool>; and the same pattern probably exists elsewhere in the codebase. I audited main (55e0d62) file by file, tracing each shared flag against the threads that actually touch it. Below is everything I found, with file/line references only (no patch — that can follow separately if there's interest).

One data point up front: there are currently zero uses of std::atomic or QAtomic* anywhere in src/.

Why these matter, briefly. Each item is a data race in the C++ memory-model sense (concurrent access, at least one write, no synchronization), which is formally undefined behavior. In practice the realistic failure modes are: (a) the compiler legally caching or hoisting a repeated read (more likely under LTO/aggressive optimization), and (b) delayed or reordered visibility on weakly-ordered CPUs — which today includes Apple Silicon Macs, Android (Oboe), and ARM Linux servers such as Raspberry Pi. For the non-bool items, torn reads of multi-byte values are also possible. On x86 most of these "work" today, which is why they've been harmless-looking for years.

Thread model as audited.

  • Client: main/Qt event thread (GUI, protocol — protocol messages arrive via queued signal, so parsing runs on main), socket thread (CSocketThread, time-critical), audio-driver callback thread (JACK/CoreAudio/ASIO/Oboe).
  • Server: main thread (including OnTimer mixing, reached via queued signal from the timer thread, and the protocol slots which lock Mutex), CHighPrecisionTimer thread (non-Windows), socket thread, CThreadPool workers, recorder thread.

A. Thread-lifecycle bRun flags (the exact #3786 class)

A1. CSocketThread::bRunsrc/socket.h:241.
Written false by the main thread in Stop() (socket.h:213); read in the thread loop (socket.h:231) on the socket thread. This is the #3786 case, unchanged on main.

A2. CHighPrecisionTimer::bRun (non-Windows) — src/util.h:1355.
Written by the main thread in Start()/Stop() (src/util.cpp:315, 339); read in the run() loop on the timer thread (src/util.cpp:348). Additionally read lock-free by a third thread: the socket thread calls pServer->IsRunning() (src/socket.cpp:744) → CServer::IsRunning() (src/server.h:136) → isActive() (src/util.h:1350) when deciding whether to wake a sleeping server. A missed bRun = false means the server's time-critical timer thread never exits its loop (Stop() then hits the 5 s wait() timeout with the thread still running). This affects every Linux/macOS server; the Windows variant is a QTimer on the main thread and is fine.

A3. CSoundBase::bRunsrc/sound/soundbase.h:196.
Written by the main thread in Start() (soundbase.h:98) and Stop() (src/sound/soundbase.cpp:71); read on the realtime audio callback thread in every backend that checks it:

  • src/sound/jack/sound.cpp:368 (read inside MutexAudioProcessCallback, but the writers don't hold that mutex, so the race remains),
  • src/sound/coreaudio-mac/sound.cpp:1026 and :1086,
  • src/sound/oboe/sound.cpp:225 (no lock at all at the point of the read).
    This flag is the only gate between the driver callback and the whole client audio processing chain.

A4. CSoundBase::bCallbackEnteredsrc/sound/soundbase.h:197.
Written true by the audio callback thread (soundbase.h:192), reset false by the main thread in Start() (soundbase.h:99), read by the GUI at src/clientdlg.cpp:1192 to detect a dead audio device. Unsynchronized write/reset/read across three call sites and two threads; a stale read here produces a false "sound card not working" verdict (or masks a real one).

B. Jitter-buffer status flags

B1. CSocket::bJitterBufferOKsrc/socket.h:127.
Written false by the socket thread (src/socket.cpp:714); read-and-reset by the main thread in GetAndResetbJitterBufferOKFlag() (src/socket.cpp:519–526) with no lock — note the class has a Mutex (used in SendPacket, socket.cpp:443), it just isn't used here. Lost updates show a green jitter-buffer LED during dropouts.

B2. CClient::bJitterBufferOKsrc/client.h:434.
Same pattern one level up: written false by the sound thread (src/client.cpp:1558), reset/read by the main thread (client.cpp:634, 641, 1084).

C. GUI-thread parameters read by the audio callback (client)

All written by plain setters on the main thread, read every frame by ProcessAudioDataIntern() on the sound thread, no atomics or locks:

Member Declared Written Read (sound thread)
bMuteOutStream src/client.h:393 client.h:281 client.cpp:1504, 1516, 1534, 1584
iInputBoost src/client.h:409 client.h:290 client.cpp:1427–1433
iReverbLevel src/client.h:407 client.h:192 client.cpp:1443–1445
bReverbOnLeftChan src/client.h:406 client.h:197 client.cpp:1445
iAudioInFader src/client.h:405 client.h:189 client.cpp:1449+

One of these is worse than a flag race: SetReverbOnLeftChan() (src/client.h:195–199) calls AudioReverb.Clear() on the GUI thread while the sound thread may be inside AudioReverb.Process() (client.cpp:1445). That is a concurrent mutation of non-trivial filter state, not just a stale boolean.

(For contrast: parameters whose setters stop/re-init/restart the sound — SetAudioChannels, SetAudioQuality, SetEnableOPUS64, SetSndCrdDev, OnRawAudioSupported, etc. — are structurally fine, though their "is the callback really stopped" handshake itself rides on the non-atomic bRun of A3.)

D. Channel state (client side)

D1. CChannel::bIsEnabledsrc/channel.h:239.
Written under Mutex by SetEnable() (src/channel.cpp:152) on the main thread at connect/disconnect (client.cpp:1039, 1056); but read without the mutex via IsEnabled() (channel.h:106) by the socket thread in PutAudioData() (channel.cpp:561). A mutex only on the writer side synchronizes nothing. (Server side is safe by accident: channels are enabled once in the constructor, server.cpp:241, before any thread exists.)

D2. CChannel::iConTimeOut — the connection-liveness counter.
Disconnect() writes iConTimeOut = 1 with no lock (src/channel.cpp:536, main thread), while the socket thread resets it under MutexSocketBuf (channel.cpp:613 via channel.h:101) and the audio path decrements it under MutexSocketBuf (channel.cpp:635–641). IsConnected() (channel.h:102) reads it with no lock from several threads. A lost update here delays or cancels a disconnect — possibly related to the comment in CClient::Stop() that "disconnect is still not working reliably" (client.cpp:1065).

D3. CChannel::iNetwFrameSize / iNetwFrameSizeFact — mismatched mutexes.
Written under Mutex in SetAudioStreamProperties() (src/channel.cpp:201–224); read under MutexSocketBuf — a different mutex — by the socket thread in PutAudioData() (channel.cpp:566). Two different mutexes provide no mutual exclusion; the packet-size check can see a torn/stale value while transport properties change.

E. Server

E1. CServer::bChannelIsNowDisconnectedsrc/server.h:260.
Written by CThreadPool workers (src/server.cpp:950, enqueued at server.cpp:710) — potentially by several workers in the same tick. The read at server.cpp:722 is synchronized (it happens after future.wait()), but two workers storing concurrently is still formally a data race. This site carries the exact comment discussed in #3786 (server.cpp:947–949):

// note that no mutex is needed for this shared resource since it is not a
// read-modify-write operation but an atomic write …

A plain bool store is not an atomic write in the C++ memory model; making the member std::atomic<bool> would make the comment true at zero practical cost.

F. Shutdown path

F1. CSocket::UdpSocket4 / UdpSocket6 in Close()src/socket.cpp:389+.
Close() (main thread) shuts the sockets and overwrites the fds with INVALID_SOCKET, with no lock, while the socket thread is concurrently using those same fds in the blocking poll/recvfrom loop of OnDataReceived(), and while SendPacket() (which does take Mutex, socket.cpp:443) may be sending from the sound thread. Close-while-in-use is the intended unblock mechanism, but the unsynchronized fd overwrite adds a stale-fd/fd-reuse hazard on top of the bRun race of A1.


Checked and found properly synchronized (for completeness)

  • CServer::PutAudioData() and both protocol slots lock the server Mutex (server.cpp:1562, 1570, 1585), so the server's put/get audio path and channel protocol state are serialized with OnTimer.
  • OnTimer holds Mutex for the whole decode phase and joins all workers via future.wait() before reading their results (server.cpp:667–719).
  • CChannel::SetAudioStreamProperties() re-initializes SockBuf/ConvBuf under MutexSocketBuf/MutexConvBuf respectively (channel.cpp:226–239); SetGain/GetGain both lock Mutex.
  • CThreadPool (src/threadpool.h) guards its stop flag and queue with queue_mutex + condition variable.
  • The recorder runs in its own QThread and communicates via queued signals only.

Suggested direction (no patch here by design)

The bool cases are one-line conversions to std::atomic<bool> using the overloaded operators (per the conclusion in #3786, the default sequentially-consistent operations are negligible at these rates). iConTimeOut and the C-class ints would become std::atomic<int>; D3 wants either one mutex or atomics for the two size fields; C's AudioReverb.Clear() needs to move onto the audio thread or under MutexAudioProcessCallback. Happy to follow up with a PR if the direction is agreed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions