Skip to content

feat(adapter): add Adapter.Reset() for in-process recovery#446

Open
retr0h wants to merge 2 commits intotinygo-org:devfrom
retr0h:feat/adapter-reset
Open

feat(adapter): add Adapter.Reset() for in-process recovery#446
retr0h wants to merge 2 commits intotinygo-org:devfrom
retr0h:feat/adapter-reset

Conversation

@retr0h
Copy link
Copy Markdown

@retr0h retr0h commented May 1, 2026

Summary

Adds Reset() to *Adapter on darwin and linux. Reset tears down the underlying transport-specific state (CoreBluetooth managers on darwin, D-Bus handles on linux) so a subsequent Enable() rebuilds them from scratch.

Depends on #445 (Enable callable after first invocation) — once that lands I'll rebase and remove this note.

Motivation

There's currently no API to throw away adapter state and start fresh on the same *Adapter. Useful for:

  • Recovery flows that hit a stale CBPeripheral handle on an otherwise-healthy central (Connect blocks forever waiting for a delegate callback that will never arrive — Reset breaks the deadlock).
  • Switching between adapters in long-running processes.
  • Cleanup in tests.

What it does

  • cancels in-flight scan via existing StopScan
  • drains pending Connect waiters via close(ch) so callers unblock and return an error rather than parking on a callback that a fresh central will never deliver
  • replaces cm / pm with fresh cbgo.NewCentralManager(nil) / cbgo.NewPeripheralManager(nil); ARC reclaims the old ones
  • zeros per-Adapter channels and handlers (poweredChan, scanChan, peripheralFoundHandler)

After Reset, calling Enable() again is required to set up fresh delegates and wait for powered-on. This relies on Enable's poweredChan-cleanup behavior in #445, which is why the two PRs are stacked.

Honest about the limits

The doc comment is explicit about what Reset does not do:

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.

I empirically validated this in a real recovery flow: after a long macOS sleep, ~20 Reset cycles over 10 hours never restored the ability to re-discover a known peripheral. The dedup table outlives any in-process API we have access to. Recovery from that specific failure mode requires self-exec or a subprocess BLE helper — out of scope for this library.

That said, Reset still has a useful niche:

  • Adapter switching (multiple adapters on the same machine).
  • Stale CBPeripheral handles on a central that's otherwise healthy.
  • Test isolation between scenarios.

Linux

Mostly a no-op — BlueZ is naturally per-call and doesn't carry the kind of cached peripheral state that wedges CoreBluetooth — but keeps the API symmetric so callers can write platform-agnostic recovery code.

Caller contract

Caller MUST ensure no Scan / Connect / DiscoverServices is in flight when Reset is called — there is no internal locking. Documented in the comment. The intended usage pattern is:

session goroutine errors out
caller calls adapter.Reset()
caller calls adapter.Enable()
caller starts a fresh session

Verification

  • go build . clean on darwin/arm64
  • Behavior validated against a Meshtastic peripheral: pre-Reset, an in-process retry after a stale CBPeripheral handle hangs forever in Connect. Post-Reset, Connect returns an error from the closed connectMap channel, the caller logs it and retries the full scan-and-connect path on the new central.

Backwards compatibility

Purely additive. Existing callers see no change.

🤖 Generated with Claude Code

retr0h added 2 commits May 1, 2026 09:34
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.
Adds Reset() to *Adapter on darwin and linux. Reset tears down the
underlying transport-specific state (CoreBluetooth managers on
darwin, D-Bus handles on linux) so a subsequent Enable() rebuilds
them from scratch.

Targeted at callers building recovery flows that need to throw away
adapter state and start fresh — adapter switching, error recovery
after a stale CBPeripheral handle, test cleanup, etc.

Honest about the limits: on darwin, Reset does NOT clear
CoreBluetooth's process-level advertisement-deduplication table.
That state survives recreating the CBCentralManager and only goes
away when the process exits. Documented in the doc comment so
callers don't reach for Reset expecting it to fix the
post-deep-sleep "peripheral won't re-discover" wedge — that one
needs process-level recovery (self-exec or subprocess), which is
outside the scope of this library.

Implementation:
  - cancels in-flight scan via existing StopScan
  - drains pending Connect waiters via close(ch) so callers
    unblock and return error rather than parking on a callback
    that the new central will never deliver
  - replaces cm/pm with fresh instances; ARC reclaims old ones
  - zeros the per-Adapter channels and handlers
  - depends on Enable being callable after first invocation
    (preceding commit)

Linux Reset is mostly a no-op — BlueZ doesn't carry the kind of
cached peripheral state that wedges CoreBluetooth — but keeps the
API symmetric so callers can write platform-agnostic recovery
code.

No behavior change for existing callers (Reset is purely additive).
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.

1 participant