fix(audio): run device lifecycle ops on the JUCE message thread (Windows)

Tester crash (dump 2026-07-15, Focusrite USB ASIO): the driver's deferred
kAsioResetRequest fires a juce::Timer on the addon's JUCE message thread
(ASIOAudioIODevice::timerCallback -> reloadChannelNames) while the Node
thread concurrently destroys the device inside setAudioDevices/stopAudio —
use-after-free, ~5 minutes after every launch.

New runDeviceLifecycleOp() marshals every binding that can create or
destroy a juce::AudioIODevice (setDevice, device-type switches, start/stop,
stream output open/close, extra-input bind/unbind, add/removeSource) onto
the message thread on Windows, serialising them with those timers. Inline
on macOS (dispatch already inline) and Linux (ALSA main-thread contract
unchanged), and inline when already on the message thread to avoid
self-deadlock. Closures capture by value and return through shared_ptr so
a timed-out dispatch that runs late can't touch the caller's dead stack.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
OmikronApex
2026-07-16 22:34:36 +02:00
co-authored by Claude Fable 5
parent 4dc68a2a28
commit 354052bc7c
3 changed files with 107 additions and 13 deletions
+38
View File
@@ -54,6 +54,44 @@ inline bool dispatchOnMessageThread(Func&& func)
return dispatchOnMessageThreadImpl(std::function<void()>(std::forward<Func>(func)));
}
// Run a device-lifecycle mutation (anything that can create or destroy a
// juce::AudioIODevice: setAudioDevices, start/stopAudio, device-type
// switches, stream-output open/close, extra-input add/remove) on the JUCE
// message thread.
//
// Why: on Windows, ASIO devices arm juce::Timers (the driver's deferred
// kAsioResetRequest, device-change detection) that fire on the message
// thread. Destroying the device from the Node thread while such a timer is
// queued or mid-callback is a use-after-free — tester crash 2026-07-15:
// ASIOAudioIODevice::timerCallback → reloadChannelNames on a freed device,
// ~5 min after start, every run (Focusrite USB ASIO reset request).
// Hopping the mutation onto the message thread serialises it with those
// timer callbacks, so a timer can never observe a half-destroyed device.
//
// Windows-only hop, by design: macOS's dispatchOnMessageThread already runs
// inline (no separate pump), and Linux/ALSA keeps its long-standing
// "called from the Node main thread" contract untouched. Inline when the
// caller already IS the message thread (engine init runs there), because
// dispatch-and-wait from the message thread would deadlock.
//
// Returns false when the dispatched work did not verifiably complete (post
// refused or 15 s timeout) — same contract as dispatchOnMessageThread.
// CAPTURE RULE: on timeout the queued closure may still run later, so the
// closure must own everything it touches — capture by value (engine
// snapshot, args) and write results through a shared_ptr, never through
// references to the caller's stack.
template <typename Func>
inline bool runDeviceLifecycleOp(Func&& func)
{
#if JUCE_WINDOWS
if (auto* mm = juce::MessageManager::getInstanceWithoutCreating())
if (!mm->isThisTheMessageThread())
return dispatchOnMessageThread(std::forward<Func>(func));
#endif
func();
return true;
}
// Pending-async-load registry: LoadVSTWorker / LoadPresetWorker block on a
// WaitableEvent until the message-thread continuation fires; doShutdown
// signals every registered event so no worker waits forever once the pump