Close the link before the budget runs out, not after
🚴 Build and Test BikeControl / Workspace tests (push) Failing after 0s
🚴 Build and Test BikeControl / Android compile check (push) Skipped

A pod that was connected, reporting battery every five seconds, and
sending no button frames at all cost most of an evening. The cause was
not in the decode: it was that the previous run never let go of the link.

`Actor::teardown` unsubscribed every notifying characteristic before
disconnecting, each under its own two-second timeout. A Click carries
five of them, so the exit path could spend ten seconds on optional work
against a supervisor budget of three. The supervisor gave up first, the
process exited, and `disconnect` was never reached — leaving BlueZ
holding the pod with no application running. The next connect inherited
that half-dead session, and a half-dead session streams battery and no
buttons, which reads exactly like broken hardware.

Measured directly: both apps stopped, `bluetoothctl devices Connected`
still listing the pod. It also explains the connect failures that came
with it — `le-connection-abort-by-local`, then `service discovery timed
out` — and why the fault came and went, since it depended on whether the
last exit happened to time out.

Nothing is lost by dropping the unsubscribes. The link going down clears
the peripheral's CCCDs anyway, so they were only ever politeness toward a
connection about to be destroyed. The heart rate actor had the same
shape with one characteristic instead of five; the trainer's safety
writes stay, because SAF-2 is not optional.

All three now say what a timed-out disconnect costs. The old message
named the symptom and not the consequence, and the consequence lands on
the *next* run — which is precisely why this hid for so long.

Also logs which pod reported which button. A pod reporting a button that
belongs to the other one is invisible downstream, because the input
carries the button and never its source.

Fixes both platforms at once: this is `crates/ble`, and Android is the
worse case — a leaked link there survives the app being swiped away.

Verified with four connect/disconnect cycles against the `−` pod: every
button registers on its documented bit (D-pad 0-3, paddle 8), and BlueZ
holds nothing afterwards. That satisfies TASK-0(d) and retires RISK-9.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 20:21:31 +02:00
co-authored by Claude Opus 5
parent 3274928a5f
commit a0190095fb
4 changed files with 51 additions and 9 deletions
+10
View File
@@ -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;