Give up the radio for the link, not for the search

Two failures on the tablet, one of them mine from an hour ago.

The real one first. A trainer that drops mid-ride could not get back:
its supervisor reconnects on its own (FR-1.10) and those attempts never
pass through `DeviceRegistry::connect`, so nothing suspended the device
list for them. On Android a GATT link that is discovering services while
a scan is running is killed by the platform, and the log has it exactly —
a reconnect at 13:07:18 dying with `Disconnected while discovering
services`, inside a list-scan session opened at 13:07:02.

My first fix was to suspend the list whenever the trainer was
mid-connect. That was drawn around the wrong thing. `Connecting` covers
the 15 s *search*, `Reconnecting` covers the backoff between attempts,
and against an asleep trainer those alternate for the whole of
RECONNECT_ATTEMPTS — so the list scan went off the air for minutes and
every other device starved with it. A pod that dropped could never be
seen again, which is what "the pods disconnect after 40 seconds" was.

The window that matters is narrower than either: connect, then discover
services. `scan::gatt_setup` marks it — an RAII guard taken by the
trainer, pod and heart rate paths the moment their search returns a
peripheral — and the device list yields only for that. Measured on the
tablet: 1.2 s of yielding for a heart rate connect, then straight back to
one session per 21 s.

Also: the "+ pod seen; the − pod speaks for the pair" line is logged once
per run of refusals rather than once per sighting. The device list
republishes several times a second and every pass re-reported a visible
pod — 274 identical lines in five minutes, burying the connect failures
the log was being read for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-27 13:24:39 +02:00
co-authored by Claude Opus 5
parent 9b1749d959
commit bbd11757ee
7 changed files with 93 additions and 5 deletions
+3
View File
@@ -324,6 +324,9 @@ async fn open_session(
in_flight: &InFlight,
) -> Result<(Session, Notifications), FtmsError> {
let peripheral = find_pod(adapter, selector, config.scan_timeout).await?;
// The GATT window — see `scan::gatt_setup`. Scanners stay off the air until
// the session is built.
let _gatt = scan::gatt_setup();
// Cancelling past this point would otherwise strand the link (FR-1.10).
in_flight.hold(peripheral.clone());
+4
View File
@@ -1321,6 +1321,10 @@ async fn connect_session(
});
let peripheral = scan::find_peripheral(adapter, selector, config.scan_timeout).await?;
// Found. Everything from here to the end of service discovery is the window
// Android kills a link for if anything else is scanning, so say so — the
// device list watches this and stays off the air until it is over.
let _gatt = scan::gatt_setup();
// From here until this function returns, cancelling the caller is the only
// thing that can leave a link open with nobody to close it. Hand the
// peripheral over now, before `connect()` — a cancellation lands wherever it
+2
View File
@@ -378,6 +378,8 @@ async fn open_session(
|d| selector.matches(d),
)
.await?;
// The GATT window — see `scan::gatt_setup`.
let _gatt = scan::gatt_setup();
// Cancelling past this point would otherwise strand the link (FR-1.10).
in_flight.hold(peripheral.clone());
+36
View File
@@ -184,6 +184,42 @@ fn discovery_closed(who: &str, outcome: &str) {
tracing::debug!(who, depth, outcome, "discovery: stop");
}
/// How many links are being built right now.
///
/// The distinction that matters on Android: two *scans* overlapping is
/// wasteful, but a scan overlapping a **GATT setup** — connect, then discover
/// services — is what the platform kills, with `Disconnected while discovering
/// services`. So this marks that narrower window, and the device list stays off
/// the air only for it.
///
/// Not the search that precedes it. A trainer that is asleep is searched for
/// every few seconds for minutes on end (`RECONNECT_ATTEMPTS`), and suspending
/// the list scan for all of that starves every *other* device of the discovery
/// it needs — a dropped pod could never be seen again, which is exactly what
/// happened on the tablet when the suspension was drawn around the whole
/// reconnect instead of around this.
static GATT_SETUP: AtomicUsize = AtomicUsize::new(0);
/// Marks a link as under construction until dropped. See [`GATT_SETUP`].
#[must_use = "the window lasts as long as the guard is held"]
pub struct GattSetup(());
pub fn gatt_setup() -> GattSetup {
GATT_SETUP.fetch_add(1, Ordering::SeqCst);
GattSetup(())
}
impl Drop for GattSetup {
fn drop(&mut self) {
GATT_SETUP.fetch_sub(1, Ordering::SeqCst);
}
}
/// True while any link is being built. Scanners must stay off the air.
pub fn gatt_setup_active() -> bool {
GATT_SETUP.load(Ordering::SeqCst) > 0
}
/// Open a discovery session and leave it open.
///
/// Paired with [`end`], and sampled meanwhile with [`peek`]. The three exist