Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 80 additions & 25 deletions internal/coremidi/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ package coremidi
/*
#cgo LDFLAGS: -framework CoreMIDI -framework CoreFoundation -framework CoreServices
#include <CoreMIDI/CoreMIDI.h>
#include <CoreServices/CoreServices.h>
#include <CoreFoundation/CFRunLoop.h>

// goMidiNotify is the exported Go callback; declared here so notifyBridgeFn can call it.
extern void goMidiNotify(int msgID, void *handle);
Expand All @@ -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"
)
Expand All @@ -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
Expand All @@ -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
Expand Down
15 changes: 13 additions & 2 deletions internal/midi/mididarwin/client_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
Loading