diff --git a/crates/ble/src/click.rs b/crates/ble/src/click.rs index 49f46f5..7a43076 100644 --- a/crates/ble/src/click.rs +++ b/crates/ble/src/click.rs @@ -602,14 +602,32 @@ impl Actor { /// (NFR-9). async fn teardown(&mut self, session: &Session) { self.release_held(); - for ch in &session.subscribed { - let unsubscribe = session.peripheral.unsubscribe(ch); - let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, unsubscribe).await; - } + // Straight to the disconnect, and deliberately no unsubscribe loop. + // + // A pod carries five notifying characteristics, and unsubscribing each + // under its own DISCONNECT_TIMEOUT could spend 10 s before this even + // reached the disconnect — against a supervisor budget of 3 s. The + // caller gave up first, the process exited, and the link was left open: + // BlueZ then held the pod with no app running, and the next run + // inherited a half-dead session that streamed battery and no buttons. + // Observed exactly that, and it cost an evening. + // + // Nothing is lost by skipping it. Dropping the link clears the pod's + // CCCDs on its own, so the unsubscribes were only ever politeness + // toward a link we are about to destroy (NFR-9). + tracing::debug!( + subscribed = session.subscribed.len(), + "click: closing the link" + ); match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { Ok(Ok(())) => tracing::info!("click: disconnected"), Ok(Err(e)) => tracing::debug!(error = %e, "click: disconnect failed"), - Err(_) => tracing::warn!("click: disconnect timed out"), + // Loud, because the cost lands on the *next* run rather than this + // one: a link we failed to close stays up in BlueZ after we exit. + Err(_) => tracing::warn!( + "click: disconnect timed out — the link may be left open, and a stale link \ + makes the pod look connected while sending nothing" + ), } let _ = self.events_tx.send(ClickEvent::Disconnected); } diff --git a/crates/ble/src/client.rs b/crates/ble/src/client.rs index cadb6e1..0c914a3 100644 --- a/crates/ble/src/client.rs +++ b/crates/ble/src/client.rs @@ -1168,7 +1168,14 @@ impl Actor { match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { Ok(Ok(())) => tracing::info!("disconnected from the trainer"), Ok(Err(e)) => tracing::warn!(error = %e, "disconnect failed"), - Err(_) => tracing::warn!("disconnect timed out"), + // The cost of this lands on the *next* run, not this one: a link we + // could not close stays up in BlueZ after the process exits, and + // the next connect inherits a half-dead session that reports some + // frames and not others. Worth saying plainly rather than as a + // bare timeout. + Err(_) => tracing::warn!( + "disconnect timed out — the link may be left open for the next run" + ), } self.set_state(ConnectionState::Idle); } diff --git a/crates/ble/src/heart_rate.rs b/crates/ble/src/heart_rate.rs index 438b060..fbd1b0c 100644 --- a/crates/ble/src/heart_rate.rs +++ b/crates/ble/src/heart_rate.rs @@ -579,12 +579,19 @@ impl Actor { /// Close the link. Bounded: this runs on the app's exit path (NFR-9). async fn teardown(&self, session: &Session) { - let unsubscribe = session.peripheral.unsubscribe(&session.measurement); - let _ = tokio::time::timeout(DISCONNECT_TIMEOUT, unsubscribe).await; + tracing::debug!(measurement = %session.measurement.uuid, "hr: closing the link"); + // No unsubscribe first. It is optional — dropping the link clears the + // strap's CCCD anyway — and on the exit path it competes for the same + // budget as the disconnect that actually matters. The Click actor lost + // that race and left links open in BlueZ; this path is one + // characteristic rather than five, but the failure is the same shape. match tokio::time::timeout(DISCONNECT_TIMEOUT, session.peripheral.disconnect()).await { Ok(Ok(())) => tracing::info!("hr: disconnected"), Ok(Err(e)) => tracing::debug!(error = %e, "hr: disconnect failed"), - Err(_) => tracing::warn!("hr: disconnect timed out"), + Err(_) => tracing::warn!( + "hr: disconnect timed out — the link may be left open, and a stale link makes \ + the strap look connected while sending nothing" + ), } let _ = self.events_tx.send(HeartRateEvent::Disconnected); } diff --git a/src-tauri/src/controller.rs b/src-tauri/src/controller.rs index 8efc024..bb400ab 100644 --- a/src-tauri/src/controller.rs +++ b/src-tauri/src/controller.rs @@ -984,6 +984,16 @@ fn apply( } } }); + // Which pod said what, before the merge collapses it. A pod + // reporting a button that belongs to the *other* one is invisible + // downstream — the input carries the button, never its source — and + // that is exactly the shape of a mis-mapped bit. + tracing::debug!( + pod = pod.as_str(), + button = button_name(button), + pressed, + "controller: button" + ); // One press is one press, however many pods reported it. let Some(pressed) = buttons.edge(pod, button, pressed) else { return;