[Bug]: TimerThread can post to a MessageManager being destroyed by shutdownJuce_GUI — SIGSEGV when a host unloads a JUCE-built plug-in
Detailed steps on how to reproduce the bug
When shutdownJuce_GUI() runs while Timer::TimerThread is still alive, the timer thread can call MessageBase::post() against a MessageManager that is concurrently being destroyed, and crash with SIGSEGV inside MessageQueue::post(). The window is opened by shutdownJuce_GUI() itself:
DeletedAtShutdown::deleteAll()destroysShutdownDetector, whose destructor callsTimerThread::applicationShuttingDown()→stopThreadAsync(). This only signals the thread (signalThreadShouldExit()+callbackArrived.signal()); it does not join it. The join lives in~TimerThread, which only runs when the lastSharedResourcePointer<TimerThread>(held perTimerobject) is released — i.e. only if everyTimerwas destroyed before shutdown.~TimerThread's ownjassertacknowledges timers can outlive the event system.MessageManager::deleteInstance()then runs immediately.~MessageManagercallsdoPlatformSpecificShutdown()(destroying the platformMessageQueue) before nullinginstance("do this last…"), so for the whole duration of the destructor,MessageBase::post()'s null-check onMessageManager::instancestill passes.- Meanwhile
TimerThread::run()can already be past itscallbackArrived.wait(...)checks, committed to amessageToSend->post(). There are two sites, and we have captured crashes at both:juce_Timer.cpp:128(first post): the thread passedcallbackArrived.wait(0)just before the shutdown signal landed.juce_Timer.cpp:135(the 300 ms "message may have been lost, post again" retry): this one is easy to hit in a plug-in during host shutdown, because the wrapper'sshutdownJuce_GUI()runs on the host's message thread while the host is no longer servicing the plug-in's queue — the postedCallTimersMessagenever runs,callTimers()never signalscallbackArrived, so the timer thread reliably sits in the 300 ms timeout and re-posts into the teardown window.
post() reads MessageManager::instance, null-checks it, then calls postMessageToSystemQueue() — a time-of-check/time-of-use race against deleteInstance() on another thread, with the window stretched to the full ~MessageManager duration by the nulling-last ordering.
Reproduction context: a JUCE-based host (statically linked JUCE 8.0.15, hosting via AudioPluginFormatManager) loads a JUCE-built VST3, opens its editor, and quits. Destroying the last AudioPluginInstance unloads the module, which runs shutdownJuce_GUI() in the plug-in's copy of JUCE; any timer still registered at that moment arms the race. Our automated gate (load plug-in → open editor → run 8 s → quit) hit it twice in one evening of runs, at both post sites. The plug-in is a trivial test compressor; nothing about it is exotic. Any host/plug-in pair where both are JUCE-built should be able to reproduce it by cycling quit-with-plugin-loaded, e.g. AudioPluginHost with the demo plug-in.
A possible fix: applicationShuttingDown() could join rather than signal (run()'s waits are all bounded at ≤ 300 ms, and callTimers() runs on the message thread — the caller — so a join from there cannot deadlock and completes within one loop iteration). Alternatively, post() and deleteInstance() could synchronize over the queue's lifetime.
What is the expected behaviour?
Unloading a plug-in module (or any shutdownJuce_GUI()) must not crash: the timer thread should be joined, or the message queue's destruction synchronized with MessageBase::post(), before the MessageManager dies.
Operating systems
macOS
What versions of the operating systems?
macOS 26.5.2 (25F84)
Architectures
Arm64/aarch64
Stacktrace
Crash 1 — the 300 ms retry post (juce_Timer.cpp:135)
Thread "JUCE v8.0.15: Timer", EXC_BAD_ACCESS / SIGSEGV, KERN_INVALID_ADDRESS at 0x0
libsystem_pthread.dylib pthread_mutex_lock
<plugin> juce::CriticalSection::enter() const (juce_SharedCode_posix.h:51)
<plugin> juce::GenericScopedLock<juce::CriticalSection>::GenericScopedLock (juce_ScopedLock.h:79)
<plugin> juce::ReferenceCountedArray<juce::MessageManager::MessageBase, juce::CriticalSection>::add (juce_ReferenceCountedArray.h:368)
<plugin> juce::MessageQueue::post (juce_MessageQueue_mac.h:67)
<plugin> juce::MessageManager::postMessageToSystemQueue (juce_MessageManager_mac.mm:436)
<plugin> juce::MessageManager::MessageBase::post() (juce_MessageManager.cpp:85)
<plugin> juce::Timer::TimerThread::run() (juce_Timer.cpp:135)
<plugin> juce::Thread::threadEntryPoint() (juce_Thread.cpp:112)
Crash 2 — the first post (juce_Timer.cpp:128)
Thread "JUCE v8.0.15: Timer", EXC_BAD_ACCESS / SIGSEGV, KERN_INVALID_ADDRESS at 0x0
libsystem_pthread.dylib pthread_mutex_lock
<plugin> juce::CriticalSection::enter() const (juce_SharedCode_posix.h:51)
<plugin> juce::GenericScopedLock<juce::CriticalSection>::GenericScopedLock (juce_ScopedLock.h:79)
<plugin> juce::ReferenceCountedArray<juce::MessageManager::MessageBase, juce::CriticalSection>::add (juce_ReferenceCountedArray.h:368)
<plugin> juce::MessageQueue::post (juce_MessageQueue_mac.h:67)
<plugin> juce::MessageManager::postMessageToSystemQueue (juce_MessageManager_mac.mm:436)
<plugin> juce::MessageManager::MessageBase::post() (juce_MessageManager.cpp:85)
<plugin> juce::Timer::TimerThread::run() (juce_Timer.cpp:128)
<plugin> juce::Thread::threadEntryPoint() (juce_Thread.cpp:112)Plug-in formats (if applicable)
VST3
Plug-in host applications (DAWs) (if applicable)
A custom JUCE-based host (statically linked JUCE 8.0.15). The mechanism applies to any host that unloads the module of a JUCE-built plug-in, and to standalone shutdownJuce_GUI() with live timers.
Testing on the develop branch
The bug is present on the develop branch
(The crashes were observed on 8.0.15; the implicated code in juce_Timer.cpp — ShutdownDetector, stopThreadAsync(), both post sites — and in juce_MessageManager.cpp — shutdownJuce_GUI() ordering, MessageBase::post(), the nulling-last ~MessageManager — is unchanged on develop as of today, verified against the current sources.)
Code of Conduct
- I agree to follow the Code of Conduct
Source: juce-framework/JUCE