Skip to content

fix(app): keep a live native BLE link from showing as Disconnected - #21430

Merged
Git-on-my-level merged 4 commits into
BasedHardware:mainfrom
Git-on-my-level:lane/ble-late-ready
Oct 14, 2026
Merged

Git-on-my-level merged 4 commits into
BasedHardware:mainfrom
Git-on-my-level:lane/ble-late-ready

Conversation

@Git-on-my-level

@Git-on-my-level Git-on-my-level commented Oct 13, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

Closes the ways the app can show a pendant as Disconnected while native BLE holds a live, ready link. In that state, a pendant that is visibly connected in iOS Settings (blue light) never streams audio until the app is killed.

Root cause (observed on a real iPhone, prod 1.0.563+1377). The app opened at 22:42:30 and sent one connect request at 22:42:34. Dart's launch connect gave up at its 60s deadline. The pendant arrived at 22:43:54, 80s after the request, and native finished service discovery at 22:43:56. After that the app process sent zero GATT reads, writes, or notify subscriptions until it was restarted. The only traffic was the native 10s RSSI poll, which runs only while peripheral.state == .connected. This is the pre-#21302 path: the timeout tore the connection down and disposed its transport. That dispose unregistered the peripheral from BleBridge, but NativeBleTransport.disconnect() returned early because Dart's mirror already said disconnected, so it never sent unmanageDevice. Native kept connecting and delivered device-ready to nobody. #21302 (merged 2026-10-13 14:45 UTC) keeps the transport on timeout. A new provider-level test reproduces the report: it fails on ce3826d2fd^ and passes on main.

This PR removes the footguns that made that path possible and still leave sibling paths open on main:

  1. Owned bridge registration. BleBridge.registerPeripheral now returns a disposer that unregisters only while the caller still owns the peripheral. Before, unregistration was by id alone, so a retired transport disposed after its replacement registered would silence the replacement's device-ready.
  2. Disconnect releases native based on intent, not the mirror. NativeBleTransport.disconnect() now calls unmanageDevice unless this transport already released native. Before, it skipped the call whenever Dart's mirrored state said disconnected, even though native keeps connecting after a Dart-side timeout. A device-ready already in flight after release is ignored, so it cannot re-adopt the link behind an explicit disconnect.
  3. DeviceService.disconnectDevice disposes the transport it drops, matching _teardownConnection. Before, the dropped transport stayed registered and could swallow the next device-ready: tap Disconnect while native is reconnecting, native reconnects, and the ready lands on an orphan whose connection no longer listens.
  4. Foreground reconcile. A new NativeLinkReconciler, bound in main.dart, runs on app resume. If the provider shows the paired device disconnected but native reports its peripheral connected, it re-checks that the same id is still paired and still disconnected after the async probe. It then calls DeviceService.ensureConnection(id, force: true) for exactly that id and publishes the result through DeviceProvider.onDeviceConnectionStateChanged, the entry point native events use. That covers both a missing transport and a DeviceService connection the provider never heard about. Native answers manageDevice for a connected peripheral by replaying device-ready (iOS replayReady / hydrateReady, Android adoptConnectedDevice), so this does not touch the radio and never starts a physical connect. It covers any future lost-ready path, not one cause.

Also:

  • If unmanageDevice fails, the transport stays unreleased, so the release can be retried and native readies are still adopted.
  • Every transport's _updateState ignores events after dispose() instead of throwing on a closed controller.
  • DeviceProvider._handleDeviceConnected admits each physical connect once, keyed on (connection, connectionEpoch) and cleared whenever the mirror disconnects. Overlapping connected events (a native connect still initializing plus a resume reconcile) no longer run capture start, battery listeners or WAL attach twice.

Line-Count-Exception: app/lib/providers/device_provider.dart | 1488 -> 1576 | the per-connect admission guard and the readoption pairing constraint (armed admission, gated persists and setup continuation) live in the provider's own connect entry point; the reconcile logic itself is split into native_link_reconciler.dart

UI-Evidence: none -- no visible UI change; main.dart only binds the device provider app-lifecycle listener

Verification

  • flutter test test/providers/device_provider_late_ready_test.dart: 14 tests. Each invariant was mutation-checked: reverting any one of the ten fixes (owner-scoped bridge release, intent-based disconnect, the ready guard after release, dispose in disconnectDevice, the reconcile native probe, the held-link republish, the reconcile id re-check, retryable failed release, the per-connect admission guard, and the admission reset on any mirror disconnect) fails exactly its test. The late-ready provider test also fails on ce3826d2fd^.
  • Independent review: codex gpt-6.1-sol via agentctl (read-only brief). Five rounds via agentctl continue on one session, each re-reviewing the delta. Rounds 1–4 found eight P2 defects in earlier revisions, all fixed and pinned by tests above: the reconcile didn't republish a link DeviceService already held; the probed id could differ from the connected id; a failed release was recorded as released; synthetic publishes across awaits (double admission, overriding a real disconnect, publishing a re-paired device); overlapping admissions for one physical connect; and an admission latch surviving provider-only disconnects. Round 5: "Nothing blocks merge in this delta."
  • bash app/test.sh (full hermetic suite + spine) with Flutter 3.44.5 on macOS: spine 138/138; suite 5,500 passed, 21 skipped, 4 failed. The four failures (apps_category_section_large_text_test ×2, ai_app_generator_large_text_test, settings_search_header_stability_test) fail identically on unmodified origin/main and are fixed by the open fix(app): restore green Flutter tests on main (#21302 #21340 #21341 #21343 follow-ups) #21425. That PR also fixes device_service_connection_failure_test, which this PR leaves alone to avoid a conflict.
  • Changed-file flutter analyze: no new issues (two pre-existing infos).

Not verified on device: the resume reconcile has not been exercised on a physical phone. The incident evidence (bluetoothd/runningboard logs) comes from the reporting iPhone.

Product invariants affected

  • INV-DATA-1 — production data-plane routing is unchanged; only the device provider's construction in main.dart changes.

Failure class

Failure-Class: FC-superseded-connection-mutates-live-session

🤖 Generated with Claude Code

View guided diff

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T01:35:45.762510Z cf6a8aa New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: aef2c67600

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// DeviceService holds the link but the provider never heard. Republish
// synchronously with the checks above: nothing can intervene, and no
// transport event will publish it.
_provider.onDeviceConnectionStateChanged(deviceId, DeviceConnectionState.connected);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate pairing when the adoption is dequeued

When resume reconciliation finds an already-connected service link, this call is not a synchronous publication: DeviceProvider.onDeviceConnectionStateChanged waits 100 ms in _connectDebouncer, and the eventual handler never rechecks the saved device ID. If another flow changes the pairing in that window—for example, rayban_meta_input_picker_sheet.dart::_connectWithDeviceService stores the selected input before awaiting its connection—the queued old-device admission can call setConnectedDevice, write the old device back to preferences, and start its connection side effects. Revalidate deviceId at admission (or make this queued reconciliation cancellable) so the pre-probe pairing check still holds when adoption executes.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74eb20a977

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}
// Otherwise native replays device-ready into a fresh transport, whose own
// connected event publishes through the same path as any native connect.
await deviceService.ensureConnection(deviceId, force: true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Carry the pairing guard through fresh connection adoption

When a resume reconcile reaches this no-existing-connection branch while the user changes pairing during ensureConnection, the eventual old-device connected event enters _handleDeviceConnected with requirePersistedPair: false. The held-service branch is now guarded, but the Ray-Ban picker persists the newly selected device before awaiting its connection (app/lib/widgets/rayban_meta_input_picker_sheet.dart:101-103), so this branch can still publish the old device, write it back to preferences, and start its capture side effects. Carry the probed pairing constraint through the fresh connection event or cancel that adoption when the persisted pairing changes.

Useful? React with 👍 / 👎.

_services = [];

try {
_nativeReleased = true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Replay ready after a failed pending release

When unmanageDevice is pending and ultimately throws, an already-in-flight onDeviceReady can be delivered during this await. Setting _nativeReleased first makes _handleDeviceReady discard it; clearing the flag in the catch cannot replay that event, and a DeviceService.disconnectDevice caller then disposes the transport, leaving Dart disconnected while native still owns the live link until another resume. Buffer/replay a pending ready when the release fails rather than dropping it permanently.

Useful? React with 👍 / 👎.

Git-on-my-level and others added 3 commits October 14, 2026 05:14
A launch connect that times out before the pendant arrives used to dispose
its transport, which unregistered the peripheral from BleBridge while
native kept connecting. The late device-ready then reached nobody and the
app showed Disconnected over a live link until it was killed. BasedHardware#21302 keeps
the transport on timeout; this closes the footguns that made the path
possible and still leave siblings open:

- BleBridge.registerPeripheral returns an owner-scoped disposer, so a
  retired transport cannot unregister its replacement.
- NativeBleTransport.disconnect releases native based on its own intent,
  not the mirrored state, and ignores a ready in flight after release.
- DeviceService.disconnectDevice disposes the transport it drops.
- NativeLinkReconciler re-adopts a link native already holds when the
  app resumes; native replays device-ready without touching the radio.
- Transports ignore state updates after dispose.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reconciler's republish of a DeviceService-held link went through
onDeviceConnectionStateChanged, so admission queued behind the 100 ms
connect debouncer. The debounced handler never rechecked the saved
device: a pairing persisted in that window (an input picker seeding
another microphone) was overwritten when the stale admission called
setConnectedDevice, writing the old device back to preferences and
starting its connect side effects.

Route the reconciler's republish through a dedicated readoptServiceLink
admission that requires the persisted pairing to still match when the
handler runs. The shared connected-event path is unchanged: onboarding
legitimately connects before persisting a first pairing.

Addresses chatgpt-codex-connector review thread PRRT_kwDOLkKqys6sA9ST.
… failed release

Two follow-ups from review:

- The reconciler's fresh-connection branch still published through the
  unguarded generic connected path. A pairing persisted while the link
  formed (an input picker seeding another microphone) was overwritten
  when that admission ran. The reconciler now queues its guarded
  readoptServiceLink admission in place of the pending generic one: the
  connect debouncer is single-slot and the reconcile continuation runs
  as the next microtask inside the 100 ms window, so the guarded
  admission always replaces the unguarded one.

- A ready delivered while unmanageDevice was pending was dropped by the
  _nativeReleased guard even when the release then failed, stranding a
  live native link until the next resume. The transport now buffers the
  pending ready and replays it after a failed release, exactly like a
  ready arriving after the failure.

Addresses chatgpt-codex-connector threads PRRT_kwDOLkKqys6sEx56 and
PRRT_kwDOLkKqys6sEx59.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d895f6fd90

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +65 to +68
// single-slot, and this runs as the next microtask after the transport's
// event, well inside its 100 ms window — so a pairing that moved on while
// the link was forming is never published.
final connection = await deviceService.ensureConnection(deviceId, force: true);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Carry the pairing guard into the fresh connected callback

When fresh adoption takes more than 100 ms, the ordinary connected debounce has already fired and its unguarded _handleDeviceConnected is running (usually waiting on DeviceService's mutex), so this later readoptServiceLink cannot replace it. Fresh evidence in the current tree is that DeviceConnection.connect emits connected before it awaits ping() and device.getDeviceInfo(this) (device_connection.dart:239-249,274-283), with Omi/Limitless initialization adding still more awaited work, which invalidates the “next microtask” assumption here. If the Ray-Ban picker persists another device during that work, the generic callback can publish the old device and rewrite the pairing before the guarded callback arrives; carry the reconciliation constraint into the connected event itself rather than relying on the debounce window.

Useful? React with 👍 / 👎.

Comment thread app/lib/providers/device_provider.dart Outdated
Comment on lines +1192 to +1194
if (requirePersistedPair && SharedPreferencesUtil().btDevice.id != deviceId) {
Logger.debug('Discarding readoption of $deviceId: pairing moved on before admission');
return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Revalidate pairing through asynchronous device publication

When the user changes pairing after this check but while _onDeviceConnected is awaiting device setup, the stale readoption is still allowed to finish: setConnectedDevice can later assign SharedPreferencesUtil().btDevice in getDeviceInfo() (device_provider.dart:389-401) and the continuation starts battery, capture, and WAL side effects for the old link. Fresh evidence in the current tree is that the newly guarded handler invokes _onDeviceConnected without carrying any expected-pair token through its async continuation. Preserve or revalidate that token through publication so a picker selection made during those BLE awaits cannot be overwritten.

Useful? React with 👍 / 👎.

Threads 4/5 on the late-ready review: a resume reconcile authorizes the
persisted pairing at probe time, but the fresh path's generic connected
admission can start running while the link is still forming
(DeviceConnection.connect emits connected before awaiting ping and
getDeviceInfo), and device setup publication awaits link checks after
admission. Arm the constraint on the provider before the link forms, gate
admission, both btDevice persists, and the setup continuation (before
battery/capture/WAL side effects and before the final publication), and
clear it on session reset.

A pairing persisted in any of those windows (the Ray-Ban input picker
seeding another microphone before awaiting its connection) is no longer
published over; verified by two provider-level regressions that fail on
the previous head.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: cf6a8aa287

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +1241 to +1243
final link = (connection, connection.connectionEpoch);
if (_admittedLink == link) return;
_admittedLink = link;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Clear the admission latch when setup is abandoned

When resume admission for A reaches this assignment and the Ray-Ban picker then seeds B while setConnectedDevice(A) is awaiting, the new pairing guard abandons A but leaves (connection, epoch) latched. If B fails, rayban_meta_input_picker_sheet.dart:101-112 restores A; subsequent resume readoptions see the same live A/epoch and are dropped by the equality check immediately above, recreating the stuck Disconnected state until a manual or physical reconnect. Fresh evidence after the prior pairing comments is that none of the newly added guard returns clears _admittedLink; only commit the latch after setup succeeds or clear it on every abandoned setup.

Useful? React with 👍 / 👎.

@Git-on-my-level
Git-on-my-level merged commit 7cfbf98 into BasedHardware:main Oct 14, 2026
40 checks passed
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