fix(darwin): make Adapter.Enable callable after first invocation#445
fix(darwin): make Adapter.Enable callable after first invocation#445retr0h wants to merge 1 commit intotinygo-org:devfrom
Conversation
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.
acouvreur
left a comment
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
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.
| // 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. |
There was a problem hiding this comment.
Is this comment really necessary ?
| if cmd.a.poweredChan != nil { | ||
| select { | ||
| case cmd.a.poweredChan <- nil: | ||
| default: | ||
| } | ||
| } |
There was a problem hiding this comment.
I believe that select can also handle nil channels so you could write:
| if cmd.a.poweredChan != nil { | |
| select { | |
| case cmd.a.poweredChan <- nil: | |
| default: | |
| } | |
| } | |
| select { | |
| case cmd.a.poweredChan <- nil: | |
| default: | |
| } |
| if cmd.a.poweredChan != nil { | ||
| select { | ||
| case cmd.a.poweredChan <- nil: | ||
| default: |
There was a problem hiding this comment.
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.
|
Also, something that should be changed is:
Which should make the CentralManagerDidUpdateState properly report the error. Because right now your implementation always expect this to actually receive a |
Summary
Three small fixes to
adapter_darwin.go'sEnable/DidUpdateStatethat surfaced while building a recovery flow that needs to re-enable the adapter after an error.1. Clear
poweredChanon every exit pathPreviously
poweredChanwas set at the start ofEnableand 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
Enableon the same*Adapterreturns `already calling Enable function` even though no Enable is actually running. Fine while consumers only ever callEnableonce at startup againstDefaultAdapter, but breaks any flow that wants to re-Enable after recovering from an error.The fix clears
a.poweredChan = nilon 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.Enablepreviously did:A freshly-constructed CBCentralManager can fire
DidUpdateStateasynchronously between the manager being constructed andSetDelegaterunning. When that happens, the powered-on callback fires before the delegate is wired up — so it's lost — andEnablewaits the full 10-second timeout for a notification that already happened.Moved
SetDelegateahead 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
Now that fix #1 nils
poweredChanon Enable completion, a late or repeatedDidUpdateState(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-blockingselectsend so the delegate never parks regardless of channel state.Why these matter together
These three changes together let
Enablebe 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/arm64Enable → use → tear down → Enablerepeatedly. Without these fixes, the secondEnablereturns `already calling Enable function`. With these fixes, it succeeds and proceeds normally.🤖 Generated with Claude Code