Skip to content
Open
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
119 changes: 115 additions & 4 deletions adapter_darwin.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,21 +44,40 @@ var DefaultAdapter = &Adapter{

// Enable configures the BLE stack. It must be called before any
// Bluetooth-related calls (unless otherwise indicated).
//
// poweredChan doubles as the "Enable currently in flight" sentinel
// (the upfront non-nil check guards against re-entry) and the
// channel the central-manager delegate signals on once
// CoreBluetooth reports powered-on. It is cleared on every exit
// path — success and timeout — so a subsequent call can run again.
// Previously the channel was set at the start of Enable and never
// cleared, so any second Enable on the same Adapter (e.g. from a
// recovery flow that re-Enables after an error) returned "already
// calling Enable function" even though no Enable was actually in
// flight.
func (a *Adapter) Enable() error {
if a.poweredChan != nil {
return errors.New("already calling Enable function")
}

// wait until powered
a.poweredChan = make(chan error, 1)

// Attach the central-manager delegate BEFORE checking
// cm.State(): a freshly-constructed CBCentralManager fires
// DidUpdateState asynchronously, and if SetDelegate happens
// after the callback runs we miss the powered-on event entirely
// and wait the full 10s timeout for a callback that already
// fired. Setting the delegate first guarantees the callback
// path is wired before the manager has had a chance to update
// state.
a.cmd = &centralManagerDelegate{a: a}
a.cm.SetDelegate(a.cmd)

if a.cm.State() != cbgo.ManagerStatePoweredOn {
select {
case <-a.poweredChan:
case <-time.NewTimer(10 * time.Second).C:
a.poweredChan = nil
return errors.New("timeout enabling CentralManager")
}
}
Expand All @@ -68,10 +87,91 @@ func (a *Adapter) Enable() error {
<-a.poweredChan
}

// wait until powered?
a.pmd = &peripheralManagerDelegate{a: a}
a.pm.SetDelegate(a.pmd)

a.poweredChan = nil

return nil
}

// Reset tears down the underlying CoreBluetooth managers so a
// subsequent Enable() rebuilds them from scratch. Useful as a
// best-effort recovery hammer when the adapter has gotten into a
// state that ordinary error handling can't unstick — for example,
// when a CBPeripheral handle has been invalidated by the OS without
// firing didFailToConnect, leaving Connect to block on a callback
// that will never arrive.
//
// What it does:
// - cancels any in-flight scan
// - drains pending Connect waiters so they unblock with a
// non-Connected peripheral and the caller's Connect returns an
// error rather than parking forever
// - drops references to the existing CentralManager,
// PeripheralManager, and delegates so ARC can deallocate them
// - zeros the powered/scan channels and per-Adapter handlers
//
// After Reset returns, Enable() is callable again on the same
// Adapter and will create fresh underlying managers (this relies
// on Enable's poweredChan-cleanup behavior).
//
// IMPORTANT — caveats discovered empirically:
//
// - Reset is NOT a complete CoreBluetooth state reset. Some
// framework-level state (notably the advertisement-deduplication
// table that suppresses repeated DidDiscoverPeripheral callbacks
// for already-known UUIDs) is held by CoreBluetooth at the
// process level and survives recreating the CBCentralManager.
// The only reliable way to clear that state is process exit.
// If you're trying to recover from "a peripheral I previously
// scanned won't reappear in subsequent Scans," Reset is unlikely
// to help on its own.
//
// - Caller MUST ensure no Scan / Connect / DiscoverServices is in
// flight when Reset is called — there is no internal locking.
// The intended usage is "session goroutine errors out → caller
// calls Reset → caller calls Enable → caller starts a fresh
// session."
//
// Despite the caveats Reset is still useful for switching between
// adapters, recovering from stale CBPeripheral handles on a
// healthy central, and for cleanup in tests.
func (a *Adapter) Reset() error {
// Best-effort scan cancel. Ignore the "not calling Scan
// function" error — Reset is the exit ramp from many states.
if a.scanChan != nil {
_ = a.StopScan()
}

// Unblock any goroutines parked in Connect waiting on a
// delegate callback that will now never arrive (the underlying
// peripheral object is about to be released). Closing the chan
// causes the receiver to read the zero cbgo.Peripheral, whose
// State() is not Connected, so Connect returns an error rather
// than a half-built Device.
a.connectMap.Range(func(key, value any) bool {
a.connectMap.Delete(key)
if ch, ok := value.(chan cbgo.Peripheral); ok {
// Guard against a concurrent send-then-close race —
// closing an already-closed chan would panic.
defer func() { _ = recover() }()
close(ch)
}
return true
})

// Drop our handles. Go's GC + cbgo's ObjC ARC bindings will
// release the underlying CBCentralManager and
// CBPeripheralManager once all references go away.
a.cm = cbgo.NewCentralManager(nil)
a.pm = cbgo.NewPeripheralManager(nil)
a.cmd = nil
a.pmd = nil
a.poweredChan = nil
a.scanChan = nil
a.peripheralFoundHandler = nil

return nil
}

Expand All @@ -85,9 +185,20 @@ type centralManagerDelegate struct {

// CentralManagerDidUpdateState when central manager state updated.
func (cmd *centralManagerDelegate) CentralManagerDidUpdateState(cmgr cbgo.CentralManager) {
// powered on?
if cmgr.State() == cbgo.ManagerStatePoweredOn {
cmd.a.poweredChan <- nil
// Guard against poweredChan being nil — Enable clears it
// once the adapter is powered on, so a late or repeated
// state update (CoreBluetooth occasionally fires multiple
// times during startup, or after a state-toggle event)
// would otherwise block forever on a nil-channel send.
// Non-blocking send so we never park the delegate goroutine
// even if the channel is set but already buffered full.
if cmd.a.poweredChan != nil {
select {
case cmd.a.poweredChan <- nil:
default:
}
}
}

// TODO: handle other state changes.
Expand Down
15 changes: 15 additions & 0 deletions adapter_linux.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,21 @@ func (a *Adapter) Enable() (err error) {
return nil
}

// Reset tears down the adapter's BlueZ-side state so a subsequent
// Enable() rebuilds it. On Linux this is mostly a no-op — D-Bus is
// naturally per-call and BlueZ doesn't carry the kind of cached
// peripheral state that wedges CoreBluetooth — but keeping the API
// symmetric with the Darwin implementation lets callers write
// platform-agnostic recovery code.
func (a *Adapter) Reset() error {
a.bus = nil
a.bluez = nil
a.adapter = nil
a.address = ""
a.scanCancelChan = nil
return nil
}

func (a *Adapter) Address() (MACAddress, error) {
if a.address == "" {
return MACAddress{}, errors.New("adapter not enabled")
Expand Down