Skip to content

fix(darwin): make Adapter.Enable callable after first invocation#445

Open
retr0h wants to merge 1 commit intotinygo-org:devfrom
retr0h:fix/enable-reentrancy-and-delegate-race
Open

fix(darwin): make Adapter.Enable callable after first invocation#445
retr0h wants to merge 1 commit intotinygo-org:devfrom
retr0h:fix/enable-reentrancy-and-delegate-race

Conversation

@retr0h
Copy link
Copy Markdown

@retr0h retr0h commented May 1, 2026

Summary

Three small fixes to adapter_darwin.go's Enable / DidUpdateState that surfaced while building a recovery flow that needs to re-enable the adapter after an error.

1. Clear poweredChan on every exit path

Previously poweredChan was set at the start of Enable and never cleared — not on success, not on timeout. The field was effectively used as both "Enable in flight" sentinel and the powered-on signal channel, but only the second role was reset.

Consequence: any second call to Enable on the same *Adapter returns `already calling Enable function` even though no Enable is actually running. Fine while consumers only ever call Enable once at startup against DefaultAdapter, but breaks any flow that wants to re-Enable after recovering from an error.

The fix clears a.poweredChan = nil on both the timeout path and the success path, treating the field as a true "currently in progress" sentinel.

2. Attach the delegate before checking cm.State()

adapter.Enable previously did:

a.poweredChan = make(chan error, 1)
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 freshly-constructed CBCentralManager can fire DidUpdateState asynchronously between the manager being constructed and SetDelegate running. When that happens, the powered-on callback fires before the delegate is wired up — so it's lost — and Enable waits the full 10-second timeout for a notification that already happened.

Moved SetDelegate ahead of the state check (and added a clarifying comment). Setting the delegate first guarantees the callback path is wired before any state transition can be observed.

3. Nil-guard the delegate's channel send

func (cmd *centralManagerDelegate) CentralManagerDidUpdateState(cmgr cbgo.CentralManager) {
    if cmgr.State() == cbgo.ManagerStatePoweredOn {
        cmd.a.poweredChan <- nil   // <-- panics-equivalent if nil
    }
}

Now that fix #1 nils poweredChan on Enable completion, a late or repeated DidUpdateState (CoreBluetooth occasionally emits multiple state updates during startup or after a Bluetooth-toggle event) would block forever on a nil-channel send, leaking the delegate goroutine. Added a nil check + non-blocking select send so the delegate never parks regardless of channel state.

Why these matter together

These three changes together let Enable be safely invoked multiple times on the same *Adapter — which is the prerequisite for any kind of in-process recovery, adapter switching, or error-retry flow on macOS. Each one is independently defensible (real bug, narrow blast radius), but the combination is what unlocks the broader use case.

Verification

  • go build . clean on darwin/arm64
  • Tested against a Meshtastic BLE peripheral with a flow that calls Enable → use → tear down → Enable repeatedly. Without these fixes, the second Enable returns `already calling Enable function`. With these fixes, it succeeds and proceeds normally.
  • No behavior change for the existing single-Enable case — same code path, just with state cleanup at the end.

🤖 Generated with Claude Code

Three small fixes to adapter_darwin.go's Enable / DidUpdateState
that surfaced while building a recovery flow that needs to re-Enable
the adapter after an error.

1. Clear `poweredChan` on every Enable exit path (both success and
   timeout). Previously the field was set at the start of Enable
   and never cleared, so any second call to Enable on the same
   Adapter returned "already calling Enable function" even though
   no Enable was actually in flight. Treats `poweredChan != nil`
   as a true "Enable currently running" sentinel.

2. Move `cm.SetDelegate(...)` ahead of the `cm.State()` check.
   A freshly-constructed CBCentralManager can fire DidUpdateState
   asynchronously before the delegate is attached; when that
   happens, Enable waits the full 10s timeout for a powered-on
   callback that already fired. Wiring the delegate first
   guarantees the callback path is in place before any state
   transition can occur.

3. Nil-guard the channel send in DidUpdateState. Now that Enable
   clears `poweredChan`, a late or repeated DidUpdateState (which
   CoreBluetooth occasionally emits on state toggles) would
   otherwise block forever on a nil-channel send. Non-blocking
   send + nil check keeps the delegate goroutine from parking.

No behavior change for existing single-Enable callers.
Copy link
Copy Markdown
Contributor

@acouvreur acouvreur left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your change. I think it is quite straightforward, although there's a lot of unnecessary comment noise in my opinion.

And I think that they do not bring any value to the reader. However, we should keep those in the commit message and the pull request.

Comment thread adapter_darwin.go
Comment on lines +48 to +57
// 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this whole comment can be left as part of the commit or the pull request, but is a bit irrelevant to the user.

Comment thread adapter_darwin.go
Comment on lines +65 to +72
// 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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this comment really necessary ?

Comment thread adapter_darwin.go
Comment on lines +116 to +121
if cmd.a.poweredChan != nil {
select {
case cmd.a.poweredChan <- nil:
default:
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that select can also handle nil channels so you could write:

Suggested change
if cmd.a.poweredChan != nil {
select {
case cmd.a.poweredChan <- nil:
default:
}
}
select {
case cmd.a.poweredChan <- nil:
default:
}

Comment thread adapter_darwin.go
if cmd.a.poweredChan != nil {
select {
case cmd.a.poweredChan <- nil:
default:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we add some logging about discarded events ? I think it is quite important instead of being silently dropped. But we can discuss that in a future evolution.

@acouvreur
Copy link
Copy Markdown
Contributor

Also, something that should be changed is:

  • Handle errors from <-poweredChan
  • Handle other state changes in CentralManagerDidUpdateState

Which should make the CentralManagerDidUpdateState properly report the error.

Because right now your implementation always expect this to actually receive a ManagerStatePoweredOn event.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Unable to build example

2 participants