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() destroys ShutdownDetector, whose destructor calls TimerThread::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 last SharedResourcePointer<TimerThread> (held per Timer object) is released — i.e. only if every Timer was destroyed before shutdown. ~TimerThread's own jassert acknowledges timers can outlive the event system.
MessageManager::deleteInstance() then runs immediately. ~MessageManager calls doPlatformSpecificShutdown() (destroying the platform MessageQueue) before nulling instance ("do this last…"), so for the whole duration of the destructor, MessageBase::post()'s null-check on MessageManager::instance still passes.
- Meanwhile
TimerThread::run() can already be past its callbackArrived.wait(...) checks, committed to a messageToSend->post(). There are two sites, and we have captured crashes at both:
juce_Timer.cpp:128 (first post): the thread passed callbackArrived.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's shutdownJuce_GUI() runs on the host's message thread while the host is no longer servicing the plug-in's queue — the posted CallTimersMessage never runs, callTimers() never signals callbackArrived, 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
Detailed steps on how to reproduce the bug
When
shutdownJuce_GUI()runs whileTimer::TimerThreadis still alive, the timer thread can callMessageBase::post()against aMessageManagerthat is concurrently being destroyed, and crash with SIGSEGV insideMessageQueue::post(). The window is opened byshutdownJuce_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.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()readsMessageManager::instance, null-checks it, then callspostMessageToSystemQueue()— a time-of-check/time-of-use race againstdeleteInstance()on another thread, with the window stretched to the full~MessageManagerduration 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 lastAudioPluginInstanceunloads the module, which runsshutdownJuce_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, andcallTimers()runs on the message thread — the caller — so a join from there cannot deadlock and completes within one loop iteration). Alternatively,post()anddeleteInstance()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 withMessageBase::post(), before theMessageManagerdies.Operating systems
macOS
What versions of the operating systems?
macOS 26.5.2 (25F84)
Architectures
Arm64/aarch64
Stacktrace
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
developbranchThe bug is present on the
developbranch(The crashes were observed on 8.0.15; the implicated code in
juce_Timer.cpp—ShutdownDetector,stopThreadAsync(), both post sites — and injuce_MessageManager.cpp—shutdownJuce_GUI()ordering,MessageBase::post(), the nulling-last~MessageManager— is unchanged ondevelopas of today, verified against the current sources.)Code of Conduct