message_manager.h
The message thread: running code on it, and holding it still.
#include <sonore/message_manager.h>- 7types
- Guides: Testing your plugin
On this page
Design notes
What it is for
Components may only be touched from one thread. A sample finishes loading on a worker, a device list changes on a driver's thread, a licence check comes back from a pool -- and every one of them has something to tell the interface. MessageManager is the one door: callAsync() queues a function for the message thread, callFunctionOnMessageThread() queues it and waits, CallbackMessage and MessageListener are the same thing as objects, and MessageManagerLock lets a background thread hold the message thread still while it does the touching itself.
The editor's 33 ms tick already carries the PARAMETER traffic and stays the right road for it (UiEventQueue is lock-free and the audio thread may push into it). This is for everything else, and none of it is for the AUDIO thread: posting allocates and takes a mutex, and a lock on the message thread from an audio callback is how a plugin deadlocks a DAW.
One queue of our own, and the OS only as an alarm clock
Every call lands in a deque owned by this object, and the OS is only asked to wake the message thread up -- once, however many calls are waiting. Posting one OS message per call is the obvious design and it fails three ways: Windows drops posted messages past 10,000 per thread, so a worker in a tight loop loses callbacks silently; a nested modal loop (a native file dialog) can reorder them; and a lock request needs to know whether it was ever delivered, which the OS will not say. The wake-up per platform:
Windows- a message-only window (HWND_MESSAGE) created on the message thread; PostMessage wakes it. The host pumps it like any other.
macOS- a CFRunLoopSource on the message thread's run loop, in the common modes so it still fires under a modal panel or a drag.
Linux- an eventfd (a pipe where there is none). runDispatchLoop polls it; inside a host, wakeUpFileDescriptor() is what to hand the host's fd support (CLAP posix-fd, VST3 IRunLoop).
The message thread is whichever thread first asks for the instance, which in a plugin is the host's UI thread and in an application its main thread.
Draining in order, a bounded amount at a time
Messages are taken ONE at a time from the front, so a callback that runs a nested loop (a modal dialog pumping messages) carries on in the right order rather than overtaking the rest of a batch it had already taken. And a wake-up drains at most a few hundred, or 20 ms, before it re-arms itself and gives the OS loop a turn: a worker posting faster than the message thread can run would otherwise freeze input and painting for as long as it posted. Windows hands out posted messages before input, paint and timers, so there a queue that has not run dry for 50 ms asks for its next batch by a timer whenever one of those is waiting.
The lock
MessageManagerLock posts a message that, when the message thread reaches it, parks the message thread inside that callback until the lock is released. That is the "safe point": between two messages, never in the middle of one, so whatever the message thread was doing is finished and nothing it owns is half-updated. While parked it runs nothing else, so two lock holders are mutually exclusive with each other and with every other callback.
The danger is the obvious one. A worker waiting for the lock while the message thread waits for the worker -- a destructor joining it -- never ends. So a lock can be given a ThreadExitSignal (woken the instant the signal is raised) or a ThreadPool job's stop flag (looked at every 2 ms), and gives up when either is set. Every worker that may be stopped from the message thread should pass one.
What is RUN on this machine: all of it on Windows. macOS and Linux are written against their documented APIs and only compiled here.
Types 7
A flag a thread is asked to stop by, which can WAKE a thread that is waiting for the message thread.
The reference's Thread carries this; this SDK's threads are std::thread and ThreadPool, so it is a small object of its own. A worker owns one (or is handed one), checks threadShouldExit() in its loop, and passes it to every MessageManagerLock it takes -- so the owner's signalThreadShouldExit(), followed by a join, cannot deadlock against a lock the worker is waiting for.
The message thread's queue, and the loop that drains it.
One per binary, created by the first getInstance() on the thread that is to be the message thread. Everything static here is safe from any thread; the members say which thread they belong to.
class CallbackMessage : public MessageManager::MessageBase
A message with a callback: subclass, implement messageCallback(), and (new MyMessage(...))->post(). The queue deletes it after it has run.
class Message : public MessageManager::MessageBase
What a MessageListener receives. Subclass it to carry data; post it with MessageListener::postMessage, which takes ownership.
Something that receives Messages on the message thread.
Messages posted to a listener that has since been deleted are dropped rather than delivered to freed memory. Deleting a listener on another thread waits for a delivery to it that is already running. One caveat the language imposes: by the time ~MessageListener runs, a derived class's own members are gone, so a listener that can be deleted on a thread other than the message thread should be deleted while holding a MessageManagerLock.
Exclusive access to the message thread, from any thread, for as long as this object lives.
On the message thread -- or nested inside another lock on the same thread -- it is gained at once. Anywhere else it waits for the message thread to reach a safe point between two messages and park there (see the header); while it is held, nothing else runs on the message thread, so this thread may touch components as if it were the message thread.
Pass the thread's ThreadExitSignal, or a ThreadPool job's stop flag, so that stopping the thread from the message thread cannot deadlock against this wait: the lock gives up and lockWasGained() says so. ALWAYS check it.
Do not, while holding it, wait for anything the message thread must do: it is parked. SendMessage to a window it owns is the classic way.
An update that can be asked for from any thread, coalesced into one call on the message thread -- and CANCELLED with a guarantee.
The callback runs under this object's lock, and cancelPendingUpdate() takes the same lock: if the callback is running, cancel waits for it to finish; once cancel returns, a pending callback will not start. That is the whole difference from an updater built on a bare flag, where a callback can start between "cancel" and the line after it. The lock is recursive, so the callback may trigger or cancel itself.
The price is the same lock: a callback that waits for a thread that is calling trigger or cancel deadlocks, and trigger blocks while a callback runs.