Skip to content

Commit ecd1079

Browse files
committed
fix: replace CFRunLoopTimer with dispatch_async_f in DispatchToMainAsync to prevent macOS dropdown deadlock
CFRunLoopTimer with kCFRunLoopCommonModes fires during NSEventTrackingRunLoopMode (NSMenu popup tracking). A .NET P/Invoke transition executing inside that tracking event loop deadlocks with AppKit on Apple Silicon macOS 12+, causing the plugin editor UI to freeze permanently when a dropdown is clicked. Replaced with dispatch_async_f on the GCD main queue, which uses a different execution context and does not exhibit this interaction. The existing _idlePending throttle in VstEditorController ensures at most one pending callback at a time. Also updates the OwnVST3 submodule: native idle timer now uses kCFRunLoopCommonModes instead of kCFRunLoopDefaultMode for consistency.
1 parent d570896 commit ecd1079

2 files changed

Lines changed: 16 additions & 129 deletions

File tree

OwnVST3

OwnVST3Host/NativeWindow/NativeWindowMac.cs

Lines changed: 15 additions & 128 deletions
Original file line numberDiff line numberDiff line change
@@ -100,60 +100,13 @@ public NSRect(double x, double y, double width, double height)
100100

101101
private delegate void dispatch_function_t(IntPtr context);
102102

103-
// CFRunLoop API - supports explicit RunLoop modes
104-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
105-
private static extern IntPtr CFRunLoopGetMain();
106-
107-
[StructLayout(LayoutKind.Sequential)]
108-
private struct CFRunLoopTimerContext
109-
{
110-
public IntPtr version;
111-
public IntPtr info;
112-
public IntPtr retain;
113-
public IntPtr release;
114-
public IntPtr copyDescription;
115-
}
116-
117-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
118-
private static extern IntPtr CFRunLoopTimerCreate(
119-
IntPtr allocator,
120-
double fireDate,
121-
double interval,
122-
ulong flags,
123-
long order,
124-
CFRunLoopTimerCallBack callout,
125-
ref CFRunLoopTimerContext context);
126-
127-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
128-
private static extern void CFRunLoopAddTimer(IntPtr rl, IntPtr timer, IntPtr mode);
129-
130-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
131-
private static extern double CFAbsoluteTimeGetCurrent();
132-
133-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
134-
private static extern void CFRelease(IntPtr cf);
135-
136-
[DllImport("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation")]
137-
private static extern void CFRunLoopWakeUp(IntPtr rl);
138-
139-
private delegate void CFRunLoopTimerCallBack(IntPtr timer, IntPtr info);
140-
141-
// Static delegate reference to prevent GC collection
142-
private static readonly CFRunLoopTimerCallBack TimerCallbackDelegate = TimerCallback;
143-
144-
// kCFRunLoopCommonModes constant (CFStringRef)
145-
// This mode set contains both NSDefaultRunLoopMode AND NSEventTrackingRunLoopMode
146-
// So our callback runs even when a dropdown menu is open (tracking mode)
147-
private static readonly IntPtr kCFRunLoopCommonModes;
148-
149-
// dlopen/dlsym for loading framework constants and dispatch queue
103+
// dlopen/dlsym for loading the GCD main queue symbol
150104
[DllImport("/usr/lib/libSystem.B.dylib")]
151105
private static extern IntPtr dlopen(string path, int mode);
152106

153107
[DllImport("/usr/lib/libSystem.B.dylib")]
154108
private static extern IntPtr dlsym(IntPtr handle, string symbol);
155109

156-
private const int RTLD_LAZY = 1;
157110
private const int RTLD_DEFAULT = -2; // Special handle for default search
158111

159112
static NativeWindowMac()
@@ -164,28 +117,23 @@ static NativeWindowMac()
164117
if (mainQueueSymbol == IntPtr.Zero)
165118
throw new InvalidOperationException("Failed to load _dispatch_main_q symbol.");
166119
_mainQueue = mainQueueSymbol;
167-
168-
// Load kCFRunLoopCommonModes from CoreFoundation. It is a CFStringRef* exported
169-
// as a data symbol, so we dereference the pointer to get the CFStringRef value.
170-
IntPtr cfHandle = dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", RTLD_LAZY);
171-
if (cfHandle != IntPtr.Zero)
172-
{
173-
IntPtr symbolPtr = dlsym(cfHandle, "kCFRunLoopCommonModes");
174-
if (symbolPtr != IntPtr.Zero)
175-
kCFRunLoopCommonModes = Marshal.ReadIntPtr(symbolPtr);
176-
}
177-
178-
if (kCFRunLoopCommonModes == IntPtr.Zero)
179-
throw new InvalidOperationException("Failed to load kCFRunLoopCommonModes from CoreFoundation.");
180120
}
181121

182122
private static bool IsMainThread() => pthread_main_np() != 0;
183123

184124
/// <summary>
185-
/// Asynchronously executes an operation on the macOS main thread.
186-
/// Uses CFRunLoopTimer with kCFRunLoopCommonModes, which ensures
187-
/// that the callback runs even when a dropdown menu is in tracking mode.
188-
/// This solves VST plugin editor freezing on macOS.
125+
/// Asynchronously executes an operation on the macOS main thread via GCD.
126+
///
127+
/// Uses dispatch_async_f instead of CFRunLoopTimer to avoid a deadlock:
128+
/// CFRunLoopTimer with kCFRunLoopCommonModes fires during NSEventTrackingRunLoopMode
129+
/// (NSMenu popup tracking). A .NET P/Invoke callback executing inside that tracking
130+
/// event loop can deadlock with AppKit on Apple Silicon macOS 12+, causing the
131+
/// plugin editor UI to freeze permanently when a dropdown is clicked.
132+
///
133+
/// GCD dispatch_async_f uses the main queue's own dispatch source, which is separate
134+
/// from the RunLoop timer infrastructure and does not exhibit this interaction.
135+
/// The _idlePending throttle in VstEditorController ensures at most one pending
136+
/// callback at a time, so no accumulation occurs during tracking mode.
189137
/// </summary>
190138
private static void DispatchToMainAsync(Action action)
191139
{
@@ -195,50 +143,8 @@ private static void DispatchToMainAsync(Action action)
195143
return;
196144
}
197145

198-
// Create GCHandle - ensures the Action is not garbage collected
199146
var handle = GCHandle.Alloc(action);
200-
201-
// Create CFRunLoopTimerContext with the GCHandle as info
202-
var context = new CFRunLoopTimerContext
203-
{
204-
version = IntPtr.Zero, // version 0
205-
info = GCHandle.ToIntPtr(handle), // our GCHandle
206-
retain = IntPtr.Zero, // no retain callback
207-
release = IntPtr.Zero, // no release callback
208-
copyDescription = IntPtr.Zero // no description callback
209-
};
210-
211-
// Create one-shot timer that fires immediately on the main RunLoop
212-
// fireDate: current time (immediately)
213-
// interval: 0 (one-shot, does not repeat)
214-
double fireDate = CFAbsoluteTimeGetCurrent();
215-
IntPtr timer = CFRunLoopTimerCreate(
216-
IntPtr.Zero, // allocator (default)
217-
fireDate, // fireDate (now, immediately)
218-
0, // interval (0 = one-shot)
219-
0, // flags
220-
0, // order
221-
TimerCallbackDelegate, // callback (use static delegate to prevent GC)
222-
ref context); // context struct
223-
224-
if (timer == IntPtr.Zero)
225-
{
226-
handle.Free();
227-
throw new InvalidOperationException("Failed to create CFRunLoopTimer");
228-
}
229-
230-
// Add timer to main RunLoop with kCFRunLoopCommonModes
231-
// This guarantees that the timer fires even when:
232-
// - NSDefaultRunLoopMode is active (normal operation)
233-
// - NSEventTrackingRunLoopMode is active (dropdown menu, scrolling, etc.)
234-
IntPtr mainRunLoop = CFRunLoopGetMain();
235-
CFRunLoopAddTimer(mainRunLoop, timer, kCFRunLoopCommonModes);
236-
237-
// Wake up RunLoop if it's waiting
238-
CFRunLoopWakeUp(mainRunLoop);
239-
240-
// Release timer - the RunLoop owns it until it fires
241-
CFRelease(timer);
147+
dispatch_async_f(_mainQueue, GCHandle.ToIntPtr(handle), DispatchCallback);
242148
}
243149

244150
/// <summary>
@@ -258,26 +164,7 @@ private static void DispatchToMainSync(Action action)
258164
}
259165

260166
/// <summary>
261-
/// CFRunLoopTimer callback - runs on the main thread (RunLoop context).
262-
/// Unwraps the Action from the GCHandle and executes it.
263-
/// The 'timer' parameter is the CFRunLoopTimer reference, 'info' is the context (GCHandle pointer).
264-
/// </summary>
265-
private static void TimerCallback(IntPtr timer, IntPtr info)
266-
{
267-
var handle = GCHandle.FromIntPtr(info);
268-
try
269-
{
270-
var action = (Action)handle.Target!;
271-
action();
272-
}
273-
finally
274-
{
275-
handle.Free();
276-
}
277-
}
278-
279-
/// <summary>
280-
/// GCD callback (used for dispatch_sync) - runs on the main thread.
167+
/// GCD callback - runs on the main thread (dispatch_sync and dispatch_async).
281168
/// Unwraps the Action from the GCHandle and executes it.
282169
/// </summary>
283170
private static void DispatchCallback(IntPtr context)

0 commit comments

Comments
 (0)