Skip to content

Tier-0 auto-reconnect give-up: replace testInjectDisconnect with mock support + on-device verification #40

Description

@itsniper

Follow-up from #37 (two-tier auto-reconnect). Split out per code-review Q2.

Background

The two-tier reconnect design leans on iOS's CBConnectPeripheralOptionEnableAutoReconnect (Tier 0) and supplements it with the library backoff ladder (Tier 1). The Tier-0 → Tier-1 handoff happens when the OS gives up: after an unexpected drop the 5-param delegate first fires didDisconnectPeripheral:...:isReconnecting:true (OS still trying), and if the OS eventually gives up it fires a second didDisconnect with isReconnecting: false, at which point the library arms its own ladder.

Problem: the mock can't drive the give-up transition

CoreBluetoothMock (Nordic) does simulate isReconnecting: true when CBConnectPeripheralOptionEnableAutoReconnect is passed, but it stores that flag per-connect and never emits the true → false give-up transition. So the "OS gave up → hand off to Tier 1" path and the .system-suppresses-library path cannot be exercised through the normal mock disconnect primitive.

Current workaround (to remove)

An internal actor test hook, BluetoothActor.testInjectDisconnect(for:isReconnecting:error:), injects a 5-param didDisconnect event directly (routed through the production handleDidDisconnect) so tests can drive isReconnecting: false. It works and exercises real handler logic, but it is a test-only seam standing in for genuine mock fidelity.

Proposed work

  1. Upstream / mock fidelity. Either contribute an upstream CoreBluetoothMock enhancement (or a local mock shim) that simulates the full OS auto-reconnect state machine: connect(auto) → drop → isReconnecting: true(recover) didConnect, or (give up) isReconnecting: false, with a configurable give-up budget. Then migrate the Tier-0 give-up tests off testInjectDisconnect and retire the hook.
  2. On-device verification of the real OS behavior, since Apple does not document the give-up budget/timing.

Specific device test (iOS 18+, real peripheral)

Environment: physical iOS 18+ device, a real BLE peripheral you can power-cycle / move out of range, ReliaBLE Demo with logging enabled.

  1. Successful OS reconnect (no Tier 1). Connect with autoReconnect: true. Briefly drop the link (move peripheral out of range, then back). Expect: didDisconnect(isReconnecting: true) → library emits .reconnecting(source: .system, nil, nil); when the peripheral returns, didConnect.connected. Assert the library ladder never armed (no .reconnecting(source: .library, ...)).
  2. OS give-up → Tier-1 handoff. Connect with autoReconnect: true. Drop the link and keep the peripheral off/out of range long enough for iOS to give up. Expect a first didDisconnect(isReconnecting: true) then, after the OS budget elapses, a second didDisconnect(isReconnecting: false) → library hands off to the Tier-1 ladder (.reconnecting(source: .library, attempt: 1, ...)), then its normal give-up after maxAttempts.
  3. Measure the undocumented budget. From the logs, record the wall-clock time and any retry pattern between the isReconnecting: true and the isReconnecting: false give-up. Capture across a few runs / conditions (screen locked, app backgrounded) to characterize it. Document findings in GettingStarted.md / the plan's deferred section.
  4. Explicit disconnect during OS reconnect. While in the .system reconnecting window, call disconnect. Expect no reconnect and a clean settle to .disconnected(reason: nil) (verifies the intentional-disconnect-vs-OS-reconnect ordering).

References

  • FR-1.2: Automatic reconnection with exponential backoff #37 two-tier auto-reconnect; docs/plans/auto-reconnect-backoff-2026-07-05.md (Deferred / follow-up).
  • Apple: CBConnectPeripheralOptionEnableAutoReconnect (iOS 17+), didDisconnectPeripheral:timestamp:isReconnecting:error:, WWDC23 "What's new in Core Bluetooth". OS give-up budget/timing is undocumented.
  • Test hook to retire: BluetoothActor.testInjectDisconnect(for:isReconnecting:error:).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions