Skip to content

Public type model: Peripheral handle + DiscoveredPeripheral - #68

Merged
itsniper merged 12 commits into
masterfrom
50-peripheral-handle-type-model
Aug 8, 2026
Merged

Public type model: Peripheral handle + DiscoveredPeripheral#68
itsniper merged 12 commits into
masterfrom
50-peripheral-handle-type-model

Conversation

@itsniper

@itsniper itsniper commented Aug 1, 2026

Copy link
Copy Markdown
Member

Closes #50. Delivers #53, #54, #55, and #56 as sequential checkpoints — they are totally coupled at the type level, so there is no green intermediate state where the snapshot exists but the handle does not.

Plan: docs/plans/peripheral-handle-type-model-2026-07-31.md
Design: docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.md

What changed

Splits the single public Peripheral value type into two:

  • DiscoveredPeripheral — the Sendable scan snapshot (the shape the old struct had), now the element type of discoveredPeripherals.
  • Peripheral — a long-lived final class control handle, interned one instance per id per manager, owning connect(autoReconnect:) / disconnect() and synchronous cached last-known metadata for SwiftUI rows.

Handles are interned in a manager-owned PeripheralHandleRegistry, so manager.peripheral(id:) is synchronous and usable before any CBCentralManager exists. The actor keeps owning identity resolution, cbPeripherals, and connection state, pushing data out through an injected PeripheralRegistryBridge. No CoreBluetooth object is ever exposed, and there are no per-peripheral actors.

Two invariants worth reviewer attention:

  • One id-resolution site. Discovery and state restoration now share resolveAndUpsertDiscovered, ending a duplication that had already drifted. It carries an explicit nil-means-keep merge rule: restoration supplies no RSSI or advertisement, and wiping them would silently downgrade a device the app had already discovered — visibly, now that handles republish that metadata. Guarded by a regression test.
  • Apply before broadcast. Handle metadata is applied before the snapshot list is broadcast, so a consumer receiving snapshot N can never read handle metadata older than N. Asserted without polling, which is what makes it a real ordering test.

All connection-state writes funnel through setConnectionState(_:for:) and all clears through clearConnectionStates(), which mirrors onto handles — a handle stuck reporting .connected after the radio was invalidated would not be stale but false. The registry is cleared on shutdown() yet deliberately survives invalidatePeripherals(), because a handle must outlive a radio reset with its metadata intact.

⚠️ Breaking changes

Pre-release, so these are intentional and unshimmed:

  1. The type split itself. discoveredPeripherals vends [DiscoveredPeripheral]; Peripheral is now a class.
  2. ReliaBLEManager.connect(to:) / disconnect(from:) removed. Connect lives on the handle (FR-2.5 called manager-primary connect a temporary milestone).
  3. Public Peripheral(id:) removed in favor of manager.peripheral(id:). The old initializer produced an unregistered value that could only ever fail to connect.
  4. Platform floors raised to the Synchronization.Mutex minimums — iOS 18, macOS 15 (was 10.15), tvOS 18, watchOS 11, visionOS 2. Consequence to accept: contributors on macOS 14 can no longer build or test the library locally.

Verification

  • swift build
  • swift test — 67 tests ✅ (11 new: interning, .peripheral sugar, known-id binding, restore interning + metadata preservation, apply-before-broadcast ordering, per-manager registry isolation, connection-state clearing, orphaned-handle behavior)
  • DocC --warnings-as-errors
  • Demo builds in the simulator ✅ (not covered by CI)

handleOrphansWhenManagerDeallocates is the retain-graph leak detector: nothing reachable from the actor may hold the manager strongly, since live stream subscribers retain the actor and would otherwise pin the manager and every handle for the process lifetime.

Notes

  • New CI leg: a compile-only matrix builds iOS/tvOS/watchOS/visionOS, so the four added platform declarations are actually enforced. This is the first PR to exercise it — worth watching. tvOS and visionOS were verified locally by swiftc -typecheck against each SDK rather than a full link, as their runtime components were not installed.
  • Generated DocC output moved from ./docs to ./user-docs (gitignored). generate-documentation --output-path empties its target, and ./docs holds hand-written plans and designs — running the CI gate locally deleted them. Publishing is unaffected: Pages deploys from the uploaded artifact, so the directory name is arbitrary. Please confirm repo Pages settings are "GitHub Actions" and not "deploy from branch → /docs".
  • Handle metadata has no change notification this phase. discoveredPeripherals doubles as the "something changed" tick, and the DocC explicitly says so rather than implying the view self-updates (FR-2.4.5.3).
  • PeripheralDiscoveryEvent is unchanged; the peripheralId correlation field is deferred to FR-8.5, which redefines advertisement→id identity (FR-8.5.4).

🤖 Generated with Claude Code

itsniper and others added 12 commits August 1, 2026 12:51
Pin every Apple platform to the lowest OS version that ships the
`Synchronization` module (iOS 18, macOS 15, tvOS 18, watchOS 11,
visionOS 2), which the peripheral handle work needs for `Mutex`.
Declaring each one explicitly is what makes that safe: an undeclared
platform would otherwise inherit SPM's default floor and fail on
`import Synchronization`. The previous `.macOS(.v10_15)` was leftover
and already inconsistent with the iOS 18 floor.

Add a compile-only CI matrix leg for iOS/tvOS/watchOS/visionOS so the
new declarations are actually enforced. Tests stay macOS-only — they
need the CoreBluetoothMock harness, not real radios.

Also move generated DocC output from ./docs to ./user-docs (gitignored).
`generate-documentation --output-path` empties its target directory, and
./docs holds hand-written plans, designs, and reviews — running the CI
gate locally deleted all of them. Publishing is unaffected: Pages
deploys from the uploaded artifact, so the directory name is arbitrary.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replace the single public `Peripheral` value type with two types:

- `DiscoveredPeripheral` — the Sendable scan snapshot (the shape the old
  struct had), now the element type of `discoveredPeripherals`.
- `Peripheral` — a long-lived `final class` control handle, interned one
  instance per id per manager, owning `connect(autoReconnect:)` and
  `disconnect()` plus synchronous cached last-known metadata.

Handles are interned in a manager-owned `PeripheralHandleRegistry` so
`manager.peripheral(id:)` can be synchronous and usable before any
`CBCentralManager` exists — interning on the actor would have forced
`async` on the library's most basic entry point. The actor keeps owning
identity resolution, `cbPeripherals`, and connection state, and pushes
data out through an injected `PeripheralRegistryBridge`.

Manager-level `connect(to:)` / `disconnect(from:)` and the public
`Peripheral(id:)` initializer are removed outright rather than
deprecated: this is pre-release, and a deprecation tier would only
preserve the wrong teaching surface. `Peripheral(id:)` in particular
produced an unregistered value that could only ever fail to connect.

Two invariants worth calling out for review:

- Discovery and state restoration now share one `resolveAndUpsertDiscovered`
  helper, ending a duplication that had already drifted apart. It carries an
  explicit nil-means-keep merge rule, because restoration supplies no RSSI or
  advertisement and wiping them would silently downgrade a device the app had
  already discovered — visibly so, now that handles republish that metadata.
- Handle metadata is applied before the snapshot list is broadcast, so a
  consumer receiving snapshot N can never read handle metadata older than N.

All connection-state writes funnel through `setConnectionState(_:for:)`
and all clears through `clearConnectionStates()`, which mirrors onto
handles. A handle stuck reporting `.connected` after the radio was
invalidated would not be merely stale, it would be false.

The registry is cleared on `shutdown()` but deliberately survives
`invalidatePeripherals()` — a handle must outlive a radio reset with its
metadata intact.

Tests: migrate every call site and helper to the handle model, keeping
`waitForDiscovered` snapshot-returning so the stream assertions still test
what the stream actually emitted. Adds 11 tests covering interning, the
`.peripheral` sugar, known-id binding, restore interning and metadata
preservation, apply-before-broadcast ordering, per-manager registry
isolation, connection-state clearing, and orphaned-handle behavior — the
last of which is the retain-graph leak detector.

Refs #53, #54, #55, #56

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rewrite the Peripheral narrative across all five catalog files and fix
every stale symbol link — CI runs the DocC gate with
`--warnings-as-errors`, so a reference to a removed symbol such as
``Peripheral/init(id:)`` fails the build.

GettingStarted now teaches `manager.peripheral(id:)`, scan →
`discovered.peripheral`, and handle connect. It also states plainly that
handle metadata carries no change notification: `Peripheral` is not
`@Observable` and publishes nothing, so apps re-read metadata inside the
`discoveredPeripherals` loop, which doubles as the change signal. Without
that, the "my devices" example would teach a list that renders once and
then silently goes stale.

Background gains a note that background scanning and state restoration
are constrained on tvOS and watchOS, now that those platforms are
declared — the central role works, but `restoreIdentifier` does not
deliver the same background behavior there, and implying parity would be
worse than saying nothing.

Doc-comment-only source updates: `PeripheralDiscoveryEvent.id` is the
CoreBluetooth UUID rather than the app-facing id, errors now surface from
handle calls, and the removed manager connect path is no longer
referenced.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`syncDevices` takes `[DiscoveredPeripheral]`, and the detail view resolves
a handle via `reliaBLE.peripheral(id:)` instead of constructing the
removed `Peripheral(id:)` value. The Demo's own SwiftData `Device` and
`DiscoveryEvent` types are unchanged.

Verified with a simulator build; the Demo is not covered by CI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mark FR-2.4.2, FR-2.4.3, FR-2.4.4, FR-2.4.5.1, FR-2.4.5.2, and FR-2.4.5.3
as delivered. FR-2.4.1, FR-2.4.5, and FR-2.5 stay open on purpose: they
also cover the GATT discovery filter, readiness, subscriptions, and
command queue, which belong to FR-10 (#52).

Point the plan's verification command at ./user-docs so following it no
longer deletes the hand-written contents of ./docs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Clearing tracked connection state mirrored `nil` onto every affected handle
but broadcast nothing, so a subscriber driven only by `connectionStateChanges`
kept rendering `.connected` forever after a radio reset. A cleared peripheral
produces no further events, so the clear is the one transition an app cannot
infer on its own, and the handle reverting to `nil` does not help — nothing
tells the app to go re-read it.

`clearConnectionStates()` now broadcasts `.disconnected(reason:
.bluetoothUnavailable)` per cleared id. `shutdown()` routes through the same
path but finishes and drops every continuation first, so the broadcast is a
no-op there: a torn-down stack ends its streams rather than emitting a final
state into them. Only `invalidatePeripherals()` reaches live subscribers,
which is exactly the gap.

The handle keeps reporting `nil` — the event describes the transition that
happened, the handle reports "no longer tracking this peripheral."

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`applyConnectionState` interned unconditionally, so the clear loop minted
brand-new handles purely to write `nil` onto them — table entries nobody
requested and nobody can observe, since a handle created later reads `nil`
anyway. Clearing now corrects an existing handle if there is one and is
otherwise a no-op.

Setting a real state still interns, and must: a handle obtained after the
transition has to report it rather than diverge from the actor's tracking.

Behavior is unchanged in practice — `setConnectionState` already interned
every tracked id before any clear could reach it — but the registry no longer
grows on a path that cannot produce an observable handle.

Also corrects the registry's documented growth policy. It cited clearing on
`shutdown()` as the mitigation, but `shutdown()` is test/harness teardown, so
a shipping app retains one handle per distinct id observed for the lifetime of
the manager. Records the real bound (distinct devices seen, not advertisement
volume) and keeps weak-value storage as deferred-not-rejected with a concrete
revisit trigger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cope

GettingStarted described `discoveredPeripherals` as emitting `Peripheral`
values, contradicting the paragraph four lines below it — the stream emits
`DiscoveredPeripheral` snapshots.

`DiscoveredPeripheral`'s registry comment also overstated the interning
guarantee. Carrying the registry keeps interning alive past the manager, but
`shutdown()` empties the handle table, so a handle resolved before a shutdown
is not `===` the one re-minted after. Scopes the guarantee to a registry
generation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`invalidatePeripheralsEmitsTerminalConnectionStateChange` raced its own
subscription and failed under CI load. Creating the stream only *enqueues*
registration as an unstructured `Task` from the nonisolated factory, so the
`await manager.bluetooth.updateState()` hop it relied on proved nothing — the
two jobs are independently enqueued. When invalidation won, the broadcast went
to zero continuations and this replay-less feed dropped the event.

Polls the existing `testConnectionStateSubscriberCount()` hook until the
subscription has landed. That hook already existed for exactly this hazard on
the restore path; this test should have used it from the start.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Five connection-lifecycle tests used `await manager.bluetooth.updateState()`
as a barrier, with the intent of letting `connectionStateChanges` registration
complete before triggering a transition. It never provided that guarantee: the
stream factory is nonisolated and dispatches `register(...)` as an unstructured
`Task`, so the two jobs are independently enqueued and awaiting an unrelated
method only proves that method ran. The feed does not replay, so losing the
race drops the event outright.

They passed because each one follows the barrier with `startScanning()` and a
discovery wait, which incidentally gives registration time to land — the same
masking that hid the bug in
`invalidatePeripheralsEmitsTerminalConnectionStateChange` until CI load
exposed it.

Adds `Mock.waitForConnectionSubscription(on:above:)`, which polls the existing
`testConnectionStateSubscriberCount()` hook against a pre-subscription
baseline, and routes all six sites through it so the file has one pattern.

Verified with three full-suite runs under saturating CPU load.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment