Public type model: Peripheral handle + DiscoveredPeripheral - #68
Merged
Conversation
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>
This was
linked to
issues
Aug 8, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mdDesign:
docs/designs/discovered-peripheral-vs-peripheral-2026-07-15.mdWhat changed
Splits the single public
Peripheralvalue type into two:DiscoveredPeripheral— the Sendable scan snapshot (the shape the old struct had), now the element type ofdiscoveredPeripherals.Peripheral— a long-livedfinal classcontrol handle, interned one instance per id per manager, owningconnect(autoReconnect:)/disconnect()and synchronous cached last-known metadata for SwiftUI rows.Handles are interned in a manager-owned
PeripheralHandleRegistry, somanager.peripheral(id:)is synchronous and usable before anyCBCentralManagerexists. The actor keeps owning identity resolution,cbPeripherals, and connection state, pushing data out through an injectedPeripheralRegistryBridge. No CoreBluetooth object is ever exposed, and there are no per-peripheral actors.Two invariants worth reviewer attention:
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.All connection-state writes funnel through
setConnectionState(_:for:)and all clears throughclearConnectionStates(), which mirrors onto handles — a handle stuck reporting.connectedafter the radio was invalidated would not be stale but false. The registry is cleared onshutdown()yet deliberately survivesinvalidatePeripherals(), because a handle must outlive a radio reset with its metadata intact.Pre-release, so these are intentional and unshimmed:
discoveredPeripheralsvends[DiscoveredPeripheral];Peripheralis now a class.ReliaBLEManager.connect(to:)/disconnect(from:)removed. Connect lives on the handle (FR-2.5 called manager-primary connect a temporary milestone).Peripheral(id:)removed in favor ofmanager.peripheral(id:). The old initializer produced an unregistered value that could only ever fail to connect.Synchronization.Mutexminimums — 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,.peripheralsugar, known-id binding, restore interning + metadata preservation, apply-before-broadcast ordering, per-manager registry isolation, connection-state clearing, orphaned-handle behavior)--warnings-as-errors✅handleOrphansWhenManagerDeallocatesis 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
swiftc -typecheckagainst each SDK rather than a full link, as their runtime components were not installed../docsto./user-docs(gitignored).generate-documentation --output-pathempties its target, and./docsholds 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".discoveredPeripheralsdoubles as the "something changed" tick, and the DocC explicitly says so rather than implying the view self-updates (FR-2.4.5.3).PeripheralDiscoveryEventis unchanged; theperipheralIdcorrelation field is deferred to FR-8.5, which redefines advertisement→id identity (FR-8.5.4).🤖 Generated with Claude Code