Skip to content

Harden Central::connect against Android 133 zombies and hangs - #2

Merged
mcginty merged 3 commits into
mainfrom
connect-reliability
Apr 22, 2026
Merged

Harden Central::connect against Android 133 zombies and hangs#2
mcginty merged 3 commits into
mainfrom
connect-reliability

Conversation

@mcginty

@mcginty mcginty commented Apr 22, 2026

Copy link
Copy Markdown
Owner

Summary

Three related connect-reliability fixes, landing together because they share surface area (CentralConfig, BlewError, Android connect/disconnect paths).

  1. Default connect timeout (cross-platform). New CentralConfig::connect_timeout: Option<Duration>, default 15s, threaded through all three backends. Elapsed timeout returns a new typed error BlewError::ConnectTimedOut(DeviceId) and emits CentralEvent::DeviceDisconnected { cause: Timeout }. Replaces Linux's prior hardcoded 30s. Opt out with Some(None) if you need the pre-0.3 unbounded behavior.
  2. Post-133 GATT cleanup (Android). onConnectionStateChange(DISCONNECTED, status=133) now calls the hidden BluetoothGatt.refresh() before close() to flush the client-side service cache. connect()'s stale-cleanup path does the same plus a 300ms back-off before issuing the fresh connectGatt() — matches the canonical Android BLE workaround and prevents the stack from silently dropping back-to-back connectGatt attempts. Central::disconnect() on Android now awaits the STATE_DISCONNECTED callback with a 2s fallback that force-closes the GATT handle (new Kotlin forceClose() entry point), so the client-IF slot is always freed even when the callback never arrives.
  3. Idempotent connect. Overlapping connect() calls for the same device used to silently orphan the first caller's completion channel. Now rejected with BlewError::ConnectInFlight(DeviceId) on Apple and Android. Built on a new atomic KeyedRequestMap::try_insert.

While fixing #3 the Android backend was refactored from RequestMap<u64> + address-keyed HashMaps to KeyedRequestMap<String, _> directly — same pattern Apple already uses. This also fixes latent orphan bugs in pending_ops / pending_discover where a status-failure path could leave entries behind.

Why

Central::connect() on Android could hang indefinitely if onConnectionStateChange never fired (radio busy, adapter thrash, status-133 zombie). Downstream consumers hitting this had no recovery path. The three changes here give callers bounded-time completion, a clean reconnect path after 133, and unambiguous semantics under racing callers.

Field note: 15s default covers the realistic worst case on Android (SMP re-bond during ACL connect + up to 5s MTU negotiation). Tunable via CentralConfig.connect_timeout; None disables.

Breaking changes

  • CentralConfig gained a field. Struct-literal construction needs ..CentralConfig::default().
  • BlewError::Timeout narrowed on Linux's connect path to BlewError::ConnectTimedOut(DeviceId). Generic Timeout still used by wait_ready / wait_powered. Upgrade-guide snippet in CHANGELOG.md.

Test plan

  • mise run fmt clean
  • mise run lint (cargo clippy --workspace -- -D warnings) clean
  • mise run deny clean
  • mise run build clean
  • cargo nextest run --workspace — 67/67 pass; new tests: KeyedRequestMap::try_insert semantics, CentralConfig default asserts Some(15s), opt-out shape.
  • cargo check -p blew --target aarch64-linux-android clean
  • ktlint crates/blew/android/**/*.kt clean
  • Manual Android: reproduce 133 scenario (power-cycle peer mid-connect) and verify ConnectTimedOut after ~15s + immediate retry succeeds. Requires a physical device + a peer that can trigger 133.
  • Manual Android: concurrent connect() on the same device → second returns ConnectInFlight, first still succeeds.
  • Manual Android: adb shell dumpsys bluetooth_manager | grep clientIf after repeated failed connects shows slots reclaimed.

Reviewer notes

  • Default bumped from the 10s discussed in planning to 15s after flagging that bonded Pixel/Samsung peers can take 8–12s end-to-end through SMP + MTU. 15s is still tunable down per call via config.
  • Linux keeps its permissive concurrent-connect behavior (bluer owns connect state); no ConnectInFlight there. Noted in CHANGELOG.
  • Apple's previous connects.insert(...) silently evicted and orphaned — the new try_insert path also fixes that cross-platform.
  • The Android pending-map refactor is bundled intentionally: the dedup check is one line on top of try_insert, and the refactor eliminates the orphan-on-failure footgun in the other maps for free.

mcginty added 3 commits April 22, 2026 11:24
- `CentralConfig::connect_timeout: Option<Duration>` (default 15s) bounds
  `connect()` on every backend; elapsed timeout returns a new
  `BlewError::ConnectTimedOut(DeviceId)` and emits
  `DeviceDisconnected { cause: Timeout }`. Replaces Linux's hardcoded 30s.
- Overlapping `connect()` calls for the same device now reject the
  second caller with `BlewError::ConnectInFlight(DeviceId)` on Apple
  and Android — previously the second caller silently orphaned the
  first caller's completion channel. Built on a new atomic
  `KeyedRequestMap::try_insert`.
- Android `disconnect()` now awaits the `onConnectionStateChange`
  callback with a 2s fallback that force-closes the GATT handle via a
  new Kotlin `forceClose()` entry point. Previously it returned
  `Ok(())` immediately, leaking the client-IF slot when the callback
  never fired.
- Kotlin: refresh()-before-close() on status=133 disconnect, plus
  refresh+300ms back-off before the next `connectGatt()` in the
  stale-cleanup path. Matches the canonical Android BLE workaround
  for the zombie state.
- Android `central.rs` refactored from `RequestMap<u64>` +
  address-keyed HashMaps to `KeyedRequestMap<String, _>` directly,
  matching Apple. Eliminates the latent orphan bugs in the other
  pending-op maps too.

Assisted-by: Claude:claude-opus-4-7
- jni_hooks.rs: module-level `# Safety` doc + allows for
  `cast_possible_truncation` / `cast_possible_wrap` / `cast_sign_loss` /
  `missing_safety_doc` — every JNI entry point shares the same "called by
  the JVM through the Kotlin bindings" contract, and narrowing jint↔u16
  casts on the JNI boundary are intentional.
- Rename `chr_uuid` locals to `char_id` to fix `similar_names` against
  the `char_uuid: JString` JNI parameter.
- Convert manual `-> impl Future { async { … } }` definitions to
  `async fn` across Android's CentralBackend / PeripheralBackend impls
  (matches Linux/Apple).
- Take `&jni::errors::Error` in `jni_err` (the helpers never move out).
- Use `try_from` + explicit `BlewError::Internal` on array-size casts
  into JNI (`new_object_array`, scan/advertise filters), and
  `u16::try_from(...).unwrap_or(0)` for the JSON property bits.
- Merge identical `DisconnectCause::LocalClose` arms (0 | 22) and
  `Ok(())` no-subscriber arms (0 | 2) per `match_same_arms`.
- Remove a `drop(JObject)` and a `mem::forget(JObject)` that clippy
  flagged as no-ops — `JObject` has no `Drop` impl, so both are dead.
- Factor the L2CAP `AcceptSender` alias to clear `type_complexity`.

No behavioral change; all tests still pass (67/67) and
`cargo ndk -t arm64-v8a clippy -p blew -- -D warnings` is now clean.

Assisted-by: Claude:claude-opus-4-7
@mcginty
mcginty merged commit 8798d34 into main Apr 22, 2026
8 checks passed
@mcginty
mcginty deleted the connect-reliability branch April 22, 2026 18:24
mcginty added a commit that referenced this pull request Apr 22, 2026
- `CentralConfig::connect_timeout: Option<Duration>` (default 15s) bounds
  `connect()` on every backend; elapsed timeout returns a new
  `BlewError::ConnectTimedOut(DeviceId)` and emits
  `DeviceDisconnected { cause: Timeout }`. Replaces Linux's hardcoded 30s.
- Overlapping `connect()` calls for the same device now reject the
  second caller with `BlewError::ConnectInFlight(DeviceId)` on Apple
  and Android — previously the second caller silently orphaned the
  first caller's completion channel. Built on a new atomic
  `KeyedRequestMap::try_insert`.
- Android `disconnect()` now awaits the `onConnectionStateChange`
  callback with a 2s fallback that force-closes the GATT handle via a
  new Kotlin `forceClose()` entry point. Previously it returned
  `Ok(())` immediately, leaking the client-IF slot when the callback
  never fired.
- Kotlin: refresh()-before-close() on status=133 disconnect, plus
  refresh+300ms back-off before the next `connectGatt()` in the
  stale-cleanup path. Matches the canonical Android BLE workaround
  for the zombie state.
- Android `central.rs` refactored from `RequestMap<u64>` +
  address-keyed HashMaps to `KeyedRequestMap<String, _>` directly,
  matching Apple. Eliminates the latent orphan bugs in the other
  pending-op maps too.

Assisted-by: Claude:claude-opus-4-7
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