Skip to content

[Bug]: TimerThread can post to a MessageManager being destroyed by shutdownJuce_GUI — SIGSEGV when a host unloads a JUCE-built plug-in #1696

Description

@jdf

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:

  1. 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.
  2. 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.
  3. 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.cppShutdownDetector, stopThreadAsync(), both post sites — and in juce_MessageManager.cppshutdownJuce_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions