From a05c6cf39f2ff022d953da22fe9901c299db0c37 Mon Sep 17 00:00:00 2001 From: Leandro Ferreira Date: Sat, 16 May 2026 09:34:58 -0300 Subject: [PATCH] fix(coremidi): run CFRunLoop on dedicated OS thread for hot-plug notifications CoreMIDI delivers MIDIClientCreate notifications on the CFRunLoop of the thread that created the client. Go goroutines have no CFRunLoop by default, so hot-plug events were registered but never delivered. Fix NewClient to: - Lock the goroutine to a dedicated OS thread (runtime.LockOSThread) - Capture CFRunLoopGetCurrent() on that thread - Block the thread on runMIDIRunLoop() for the client lifetime - Stop the run loop in Dispose() via CFRunLoopStop Also remove m.client.Dispose() from Stop() so the notification run loop survives stop/start capture cycles. Add Close() for full teardown. --- internal/coremidi/client.go | 105 ++++++++++++++++------ internal/midi/mididarwin/client_darwin.go | 15 +++- 2 files changed, 93 insertions(+), 27 deletions(-) diff --git a/internal/coremidi/client.go b/internal/coremidi/client.go index bac1511..480ad70 100644 --- a/internal/coremidi/client.go +++ b/internal/coremidi/client.go @@ -6,7 +6,7 @@ package coremidi /* #cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation -framework CoreServices #include -#include +#include // goMidiNotify is the exported Go callback; declared here so notifyBridgeFn can call it. extern void goMidiNotify(int msgID, void *handle); @@ -20,10 +20,22 @@ static void notifyBridgeFn(const MIDINotification *msg, void *refCon) { static OSStatus newMIDIClientWithNotify(CFStringRef name, void *refCon, MIDIClientRef *outClient) { return MIDIClientCreate(name, notifyBridgeFn, refCon, outClient); } + +// runMIDIRunLoop drives the current thread's CFRunLoop until CFRunLoopStop is +// called on it. A 10-second iteration timeout is used so that a stale +// kCFRunLoopRunTimedOut result simply re-enters the loop rather than exiting, +// while kCFRunLoopRunStopped causes an immediate clean return. +static void runMIDIRunLoop(void) { + for (;;) { + int r = (int)CFRunLoopRunInMode(kCFRunLoopDefaultMode, 10.0, false); + if (r == kCFRunLoopRunStopped) return; + } +} */ import "C" import ( "fmt" + "runtime" "runtime/cgo" "unsafe" ) @@ -34,6 +46,7 @@ type Client struct { NotifyCh <-chan int32 // receives MIDINotificationMessageID values; nil if unsupported notifyCh chan int32 notifyHdl cgo.Handle + runLoop C.CFRunLoopRef // the CFRunLoop on which the MIDI client was created } // goMidiNotify is called from the C notifyBridgeFn when CoreMIDI fires a @@ -57,38 +70,80 @@ func goMidiNotify(msgID C.int, handle unsafe.Pointer) { } } -// NewClient creates a CoreMIDI client with the given display name and registers -// a notification callback so that device-change events are forwarded to the -// returned Client.NotifyCh channel. The cgo.Handle embedded in Client must be -// released by calling Dispose() when the client is no longer needed. -func NewClient(name string) (client Client, err error) { - notifyCh := make(chan int32, 16) - h := cgo.NewHandle(notifyCh) +// NewClient creates a CoreMIDI client on a dedicated OS thread that runs a +// CFRunLoop. +// +// CoreMIDI delivers setup-change notifications (device connect/disconnect) on +// the CFRunLoop of the thread that called MIDIClientCreate. Go goroutines do +// not have a CFRunLoop by default, so without this fix the notification +// callback is registered but never invoked. +// +// The fix: lock a goroutine to a fresh OS thread, create the CoreMIDI client +// there, capture the thread's CFRunLoopRef, then block the thread on +// runMIDIRunLoop() for the lifetime of the client. Dispose() stops the run +// loop, which unblocks the thread and allows it to exit cleanly. +func NewClient(name string) (Client, error) { + type result struct { + client Client + err error + } + resultCh := make(chan result, 1) + + go func() { + // Bind this goroutine to a dedicated OS thread so that + // CFRunLoopGetCurrent() returns a stable, thread-local run loop and + // CoreMIDI can deliver notifications to it. + // We intentionally do NOT defer runtime.UnlockOSThread(): the goroutine + // stays alive driving the CFRunLoop, and Go terminates the locked OS + // thread automatically when the goroutine returns. + runtime.LockOSThread() + + notifyCh := make(chan int32, 16) + h := cgo.NewHandle(notifyCh) - stringToCFString(name, func(cfName C.CFStringRef) { var clientRef C.MIDIClientRef - osStatus := C.newMIDIClientWithNotify(cfName, unsafe.Pointer(uintptr(h)), &clientRef) - if osStatus != C.noErr { - err = fmt.Errorf("%d: failed to create a client", int(osStatus)) - } else { - client = Client{ - client: clientRef, - NotifyCh: notifyCh, - notifyCh: notifyCh, - notifyHdl: h, + var createErr error + + stringToCFString(name, func(cfName C.CFStringRef) { + osStatus := C.newMIDIClientWithNotify(cfName, unsafe.Pointer(uintptr(h)), &clientRef) + if osStatus != C.noErr { + createErr = fmt.Errorf("%d: failed to create a client", int(osStatus)) } + }) + + if createErr != nil { + h.Delete() + resultCh <- result{err: createErr} + return } - }) - if err != nil { - h.Delete() - } - return + rl := C.CFRunLoopGetCurrent() + resultCh <- result{client: Client{ + client: clientRef, + NotifyCh: notifyCh, + notifyCh: notifyCh, + notifyHdl: h, + runLoop: rl, + }} + + // Block this OS thread on the run loop so CoreMIDI can deliver + // notifications. Returns only when Dispose() calls CFRunLoopStop. + C.runMIDIRunLoop() + }() + + r := <-resultCh + return r.client, r.err } -// Dispose releases the cgo.Handle associated with the notification channel. -// It must be called when the client is no longer needed to avoid a memory leak. +// Dispose stops the CoreMIDI notification run loop and releases the cgo.Handle. +// After Dispose the Client must not be used again. func (c *Client) Dispose() { + // Stop the run loop first so the OS thread can exit cleanly before we + // delete the handle it references. + if c.runLoop != 0 { + C.CFRunLoopStop(c.runLoop) + c.runLoop = 0 + } if c.notifyHdl != 0 { c.notifyHdl.Delete() c.notifyHdl = 0 diff --git a/internal/midi/mididarwin/client_darwin.go b/internal/midi/mididarwin/client_darwin.go index b9f25ea..0a96bf4 100644 --- a/internal/midi/mididarwin/client_darwin.go +++ b/internal/midi/mididarwin/client_darwin.go @@ -221,8 +221,11 @@ func (m *ClientMid) closeOutCh() { }) } -// Stop halts MIDI capture, disconnects the port connection, drains in-flight -// callbacks via wg.Wait, and calls Dispose to release the CoreMIDI cgo.Handle. +// Stop halts MIDI capture and disconnects the port connection. +// It intentionally does NOT call client.Dispose() so that the CoreMIDI +// notification run loop keeps running after capture stops — WatchDevices must +// continue to receive hot-plug events even between capture sessions. +// Dispose is called only on full client teardown (application shutdown). func (m *ClientMid) Stop() error { m.mu.Lock() if !m.capturing { @@ -235,6 +238,14 @@ func (m *ClientMid) Stop() error { m.wg.Wait() m.closeOutCh() + return nil +} + +// Close tears down the CoreMIDI client completely, stopping the notification +// run loop and releasing all resources. Call this only when the client will +// never be used again (e.g. application shutdown). +func (m *ClientMid) Close() error { + _ = m.Stop() m.client.Dispose() return nil }