Skip to content

Instance-isolated BluetoothActor: retire @globalActor singleton (#65) - #66

Merged
itsniper merged 14 commits into
masterfrom
65-bluetoothactor-instance-isolation
Jul 31, 2026
Merged

Instance-isolated BluetoothActor: retire @globalActor singleton (#65)#66
itsniper merged 14 commits into
masterfrom
65-bluetoothactor-instance-isolation

Conversation

@itsniper

Copy link
Copy Markdown
Member

Closes #65. Follow-up to #42.

Replaces the process-wide @globalActor BluetoothActor.shared singleton with a plain actor BluetoothActor instance owned per ReliaBLEManager. Each manager is now a fully isolated stack — its own actor, CBCentralManager, discovered peripherals, connection state, and streams. Public API on ReliaBLEManager is source-compatible (no signature changes; still Sendable, still @MainActor-callable).

Design doc: docs/designs/bluetoothactor-instance-isolation-2026-07-19.md (oracle-reviewed, marked IMPLEMENTED).

What changed

  • De-globalized the actor — dropped @globalActor/static let shared; added init(log:reconnectPolicy:restoreIdentifier:) (config only, no central created in init — lazy-auth contract preserved). ReliaBLEManager owns an internal bluetooth instance.
  • Collapsed ensureInitializedensureCentralManager() — idempotent, actor-serialized; stream/snapshot getters stay non-creating, operational methods gate on it. Stream factories remain nonisolated so the manager's sync getters (var state, …) don't become async (compile-time proof added to the Sendable test).
  • Deinit-safe teardownEventPipeline + TaskRegistry as @unchecked Sendable nonisolated let boxes; deinit finishes the pipeline and cancels tasks. Added a terminal internal shutdown() that clears volatile state only (persisted reconnect intent survives for cold relaunch). Consumer-task-before-factory ordering in setupCentralManager().
  • Faithful state-restoration tests — replaced the shim-injection backdoor with a spec-based simulateStateRestoration fixture, so willRestoreState fires during central init (the real production event). Deleted restoreDeliveryWaiters, testDeliverWillRestoreStateThroughDelegate, testSetRestoreIdentifier, testClearDiscoveredSnapshotsPreservingLiveReferences, and the restore-id-mismatch warning path.
  • New tests validating the migration — two managers with distinct restore ids holding two live stacks simultaneously with independent state; auth-cancellation isolation between managers; disconnected-restore defensive unit test.
  • Docs — AGENTS.md/CLAUDE.md + DocC (Concurrency.md, GettingStarted.md) rewritten to the one-stack-per-manager model: unique-restore-id rule (both halves), process-global authorization, shared radio, stream-retains-actor lifetime.
  • Upstream gaps docdocs/plans/corebluetoothmock-upstream-gaps-2026-07-21.md captures three verified CoreBluetoothMock 1.0.6 limitations (forced isScanning on restored scan key; no .disconnected restored state; no on-demand willRestoreState) that keep a couple of scenarios as direct-handler unit tests.

Verification

  • swift build + swift test green — 56 tests pass.
  • Demo app audited: zero singleton references, single-manager wiring unaffected, builds clean on iOS Simulator.
  • Each commit is an independently green checkpoint (see history).

Notes

  • Pre-release library — documented as current behavior, no migration/changelog notes.
  • Known minor pre-existing DocC warning: an unresolvable BluetoothActor symbol link in a ReliaBLEManager.swift comment (actor is internal). Not addressed here.

🤖 Generated with Claude Code

itsniper and others added 12 commits July 21, 2026 22:18
Route the test-only willRestoreState hook through the real
BluetoothDelegateShim → AsyncStream → process(_:) path and expose it via
Mock.simulateWillRestoreState, replacing the process(.willRestore)
backdoor. Groundwork for the instance-isolation migration (#65).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace @globalActor BluetoothActor.shared with a plain actor owned by
each ReliaBLEManager. Add EventPipeline/TaskRegistry teardown boxes,
ensureCentralManager(), terminal shutdown(), and migrate the test
harness to manager.bluetooth.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Document retain graph, scrub singleton wording from Sources/, and
replace the makeManager auto-shutdown hack with Mock.tearDown(_:)
(serialized suite still tears down the previous stack by default;
tearDownPrevious: false enables multi-stack tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add spec-based Mock.installStateRestoration / makeRestoredManager,
migrate faithful restore tests to real central-init willRestoreState,
delete shim-injection restore machinery, and keep empty-filter +
defer-scan as direct-handler unit tests (mock forces isScanning).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add two-manager independent-state and auth-cancellation isolation
tests (concurrent live stacks), demote disconnected restore seeding
to a direct-handler unit test, and extend testHandleWillRestoreState
with peripheralIds.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upstream-ready feature request for isScanning-on-restore, disconnected
restored peripherals, and on-demand willRestoreState (item 7).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace the stale @globalActor/singleton narrative with the shipped
model: each ReliaBLEManager owns its own BluetoothActor instance and is
a fully isolated stack (actor, central, discovered peripherals,
connection state, streams).

- AGENTS.md: rewrite the "Swift Concurrency" section — instance actor
  owned by the manager, one stack per manager, process-global auth,
  shared radio, and the two-halves restore-id rule (unique among live
  managers, reused across launches).
- Concurrency.md: replace the @globalActor diagram/prose with the
  per-manager actor instance and a "One stack per manager" section
  covering per-stack discovery, global auth, shared radio, restore-id
  rule, and stream-retains-actor lifetime.
- GettingStarted.md: correct the now-false "process-wide actor
  singleton" config note (config is per manager) and add multi-manager
  guidance.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Record the orchestration progress log — all 7 work items landed across
checkpoints 8b5a6b4, 0517264, 1323b2d, 5a4d1f0 (code/tests) plus b9819d5
and a4ee8c5 (docs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The public ReliaBLEManager doc comment used a ``BluetoothActor`` symbol
link, but BluetoothActor is internal and absent from the public symbol
graph, so `docc convert --warnings-as-errors` failed to resolve it. Use
plain code font instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Broadcast authorization-derived state on the first ensureCentralManager()
call even when no central is created; guard process(_:) after shutdown;
drop unused manager log property; correct Concurrency.md lazy-init wording;
add regression test for notDetermined init broadcast.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The four stream registrars inserted into their continuation maps
unconditionally, so a stream created after shutdown() left a live-but-
orphaned continuation that never finished — the consumer's `for await`
would hang forever. Guard each registrar with
`guard !isShutdown else { continuation.finish(); return }`, symmetric
with shutdown() finishing already-registered subscribers, so "terminal
means terminal" holds on every path. Adds a test asserting a post-
shutdown subscription completes immediately.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@itsniper itsniper left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

DocC changes need better organization

Comment thread Sources/ReliaBLE/Documentation.docc/GettingStarted.md Outdated
Comment thread Sources/ReliaBLE/Documentation.docc/GettingStarted.md Outdated
Comment thread Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md Outdated
Comment thread Sources/ReliaBLE/Documentation.docc/Topics/Concurrency.md
itsniper added 2 commits July 23, 2026 17:05
Use peer non-restoring/restoring CBCentralManagerDelegate shims so
willRestoreState is only present when a restore identifier is set,
matching CBCentralManagerOptionRestoreIdentifierKey. Demo injects a
nil-safe optional bleManager environment instead of constructing a
second central (or fatalErroring) when SwiftUI evaluates defaultValue.
@itsniper
itsniper merged commit bb2d37b into master Jul 31, 2026
3 checks passed
@itsniper
itsniper deleted the 65-bluetoothactor-instance-isolation branch July 31, 2026 17:43
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.

Refactor BluetoothActor to instance isolation (retire @globalActor) for multi-central support and faithful state-restoration tests

1 participant