Skip to content

Ownership tokens for every BLE handle - #1

Open
lockevod wants to merge 17 commits into
mainfrom
review-hardening
Open

Ownership tokens for every BLE handle#1
lockevod wants to merge 17 commits into
mainfrom
review-hardening

Conversation

@lockevod

@lockevod lockevod commented Sep 7, 2026

Copy link
Copy Markdown
Owner

No mezclar antes de probarlo en el Karoo. Nada de esto está validado en dispositivo, y son rutas BLE que un test JVM no puede alcanzar. Ver "Validación pendiente" al final.

Sustituye los guards volátiles / de generación por tokens de identidad, de modo que validar un callback y mutar en su nombre ya no puedan separarse por un cambio de fuente, sesión o intento que caiga en medio.

Qué cambia

ZycleClient — una operación GATT expirada retira el handle en lugar de avanzar la cola por detrás de una operación que Android todavía tiene. Desatascar en sitio no puede cancelarla, así que su callback tardío completa la operación equivocada, cancela el watchdog equivocado y bombea una tercera con la segunda aún en el controlador. connect() posee ahora su intento mediante un token que stop() limpia: un connectGatt() que retorna después de stop() cierra su propio handle en vez de instalar un huérfano que se queda el rodillo el resto de la salida.

MirrorServer — un AdvertiseCallback por intento, más un registro de proceso con los callbacks retirados mientras su start seguía sin resolver. El callback es el registro del controlador y un stop emitido con el start en vuelo lo descarta el stack, así que esos se barren con backoff hasta que su propia entrega demuestre que desaparecieron. Uno cuyo resultado ya se conoce se para una vez y se suelta.

BridgeService — un token para la fuente de recepción y otro para la instancia de emisión. El de emisión cierra que una escritura de control entrada por el mirror viejo alcance el rodillo nuevo. La escritura se despacha fuera del monitor: writeCharacteristic es un binder call que si no stopEmit() esperaría desde el hilo principal.

Persistencia de ERG bias — el learner corre en cada muestra y el plazo se evalúa por separado, así que un bias que converge dentro del intervalo ya no se retiene hasta el apagado.

Proceso

Cuatro rondas de review, dos externas y adversariales. Tres arreglos se revirtieron después de que la review falsara la premisa en que se apoyaba cada uno, y el razonamiento queda como comentario en el sitio donde estaban para que no se reintenten. Un defecto encontrado en la re-review dirigida —un anuncio ya resuelto adoptado como huérfano permanente— está corregido.

52 tests, 0 fallos. assembleDebug OK. JDK 17.

Límites conocidos

  • El barrido de advertising no puede garantizar la retirada: stopAdvertising() nunca confirma la eliminación y un start puede quedarse en vuelo indefinidamente. El barrido insiste, no promete. Documentado en el propio registro.
  • Los tests cubren contratos de coordinador, no interleavings de Android. La KDoc del test declara explícitamente los huecos, incluidos tres puramente lógicos. Robolectric no movería esa línea, así que no se añade.

Validación pendiente

La secuencia de más riesgo, en el rodillo real: start A aceptado sin callback → watchdog → retry con B exitoso → callback de A muy tardío o perdido → stop/restart → Bluetooth OFF/ON.

Tres cosas a comprobar:

  1. Que solo queda una inscripción conectable visible.
  2. Que ningún barrido de huérfanos para nunca el anuncio vivo.
  3. Que bleAdvOk coincide con la presencia real en el aire.

Incluye también b023781 y 638c780, ya presentes en la rama.

lockevod and others added 3 commits July 29, 2026 13:25
df5d112 committed everything that USES ErgBias — the import in BridgeService, the
ergBiasW argument to PowerCorrection, the correctCommanded path — but the class
itself and its tests were still untracked, so that commit does not compile.

No behaviour change on top of df5d112; this is the missing half of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01R4wR6MG97CqNiC9shgQSvf
A four-reviewer audit (three Opus passes on separate axes, one adversarial
Codex pass) found 19 issues; ten are fixed here. Two further Codex rounds then
reviewed the fixes themselves and broke twelve and three of them respectively,
which is the same lesson as 000b6f3 — the fixes need the adversary more than
the original code does.

Proxy:
- The Zycle level anchor survived a trainer dropout, so the first frame back
  was differenced against a level from before it and the whole gap reached the
  app as one rider button press. Re-anchor instead, without moving what the app
  sees. This is the "sometimes the proxy misbehaves" symptom.
- Four paths left BLE advertising dead for the rest of the ride: an exhausted
  retry budget, a start watchdog that cleared its latch without retrying or
  reporting, a synchronous throw, and openGattServer returning null. All four
  now enter one backoff loop with no attempt cap; a held profile is replayed
  when the server finally opens.
- nudgeResistance wrote 0x04 straight past the mirror, so ErgBias never saw the
  op that ends ERG and kept measuring against a dead command — saturating the
  bias and PERSISTING it, which started the next ride 30 W out.
- The servo-step budget was armed by writes that never reached the trainer, and
  survived a dropout to eat the rider's next press.
- elapsedRealtime for every timeout and freshness decision; wall clock only for
  log timestamps. CorrectedFeed already documented why.

Energy (all in the idle state, which is the default one):
- The wake lock followed the master switch, not the trainer link, blocking
  suspend for every hour the app sat armed with no trainer. It now follows the
  link, lingering past the reconnect delay so we never suspend with no scan up.
- The cold scan ran at BALANCED (25% radio duty) indefinitely. A search before
  any connection is not the mid-ride reacquisition the comment defends.
- The ERG bias was written to prefs on every whole-watt flip. Once a minute.

Performance:
- The Monitor re-rendered at the trainer's packet rate on the same looper the
  notify fan-out posts to; the 1 Hz poller already covered it.
- Every reconnect blacked out data for 0.6-2 s because the whole read burst was
  queued ahead of the subscribes. The data characteristics go first; the
  cold-cache invariant gates on the queue draining, not on the first subscribe.

From the review rounds on the fixes: a generation guard so a replaced source's
callbacks cannot drive the live one (validated on main, or the check is
check-then-act), a lock making the server/profile handover atomic, stopped
guards so a late callback cannot re-advertise a closed server, and try/finally
around the burst so nothing can hold the GATT queue shut.

Diagnostics, because none of the above is observable otherwise: a session
header with the build and the correction values, the level decision (raw,
shown, and whether it was scored servo or rider), wake lock transitions, scan
start with its mode, and a state line a minute so a quiet log is informative
rather than ambiguous. Log cap raised to 48 MB: at 16 MB rotation was throwing
away the start of the ride, which is the part you turned logging on to see.

Not verified on hardware. No test covers any file touched here.
… they broke

Replaces the volatile/generation guards with identity tokens, so validating a
callback and mutating on its behalf can no longer be split by a source, session
or attempt switch landing in between.

ZycleClient: a timed-out GATT op retires the handle rather than advancing the
queue behind an operation Android still holds. Unsticking in place cannot cancel
that op, so its late callback completes the wrong one, cancels the wrong watchdog
and pumps a third while the second is still on the controller; deferring the
retirement to an Nth consecutive timeout fails for the same reason, since that
callback also resets the counter. connect() now owns its attempt with a token
that stop() clears, so a connectGatt() returning after stop() closes its own
handle instead of installing an orphan that keeps the trainer for the whole ride.

MirrorServer: one AdvertiseCallback per attempt, plus a process-wide registry of
callbacks retired while their start was still unresolved. The callback IS the
controller registration and a stop issued mid-start is dropped by the stack, so
those are swept with backoff until their own delivery proves them gone. One whose
result is already known is stopped once and released — adopting it would strand
it in the registry for the life of the process.

BridgeService: one token for the receive source, one for the emit instance. The
emit token closes a control write that entered the old mirror and reached the
replacement trainer. The write dispatches outside the monitor: writeCharacteristic
is a Binder call that stopEmit() would otherwise wait on from the main thread.

ErgBias persistence: the learner runs on every power sample and the deadline is
evaluated independently, so a bias converging inside the interval is no longer
held back until shutdown.

Four review rounds, two of them external and adversarial. Three fixes were
reverted after review falsified the premise each rested on, and the reasoning is
kept as comments where they sat so they are not retried. Two limits stand: the
advertising sweep cannot guarantee retirement, because stopAdvertising() never
confirms removal and a start can stay in flight indefinitely; and none of this is
device-validated. The riskiest sequence to try on the Karoo is a start accepted
without a callback, its watchdog, a successful retry, then the first callback
arriving very late or never, followed by stop/restart and Bluetooth off/on.
Copilot AI lite review requested due to automatic review settings September 7, 2026 17:59

Copilot AI 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.

🔵 Needs a closer look

It changes multiple high-risk BLE lifecycle/concurrency paths (GATT, advertising, wake locks) that require device-level validation to confirm correctness under real Android interleavings.

Pull request overview

This PR hardens BLE runtime coordination by replacing volatile/generation guards with identity tokens (ownership) so late/stale callbacks cannot mutate state belonging to a newer session/attempt, and by improving operational robustness (timeouts, retries, logging, and clock correctness) around trainer receive/emit paths.

Changes:

  • Add shared ownership/coordinator primitives (identity ownership, GATT session retirement on timeout, advertising attempt coordination, ERG bias persistence throttling) and JVM contract tests for them.
  • Update BLE client/server coordination to retire stale handles/advertising attempts safely and to reduce risk from late callbacks and lost stack callbacks.
  • Switch staleness/heartbeat timing to elapsedRealtime(), adjust wake-lock behavior to follow the trainer link, and rate-limit/persist ERG bias updates.
File summaries
File Description
app/src/test/java/com/enderthor/trainerbridgeble/RuntimeHardeningTest.kt Adds JVM-level contract tests for identity ownership and coordinator behavior.
app/src/test/java/com/enderthor/trainerbridgeble/correction/ErgBiasTest.kt Adds unit tests covering ERG bias learning, clamping, and regressions.
app/src/main/java/com/enderthor/trainerbridgeble/RuntimeHardening.kt Introduces identity-ownership and coordinator primitives plus ERG bias persistence throttling.
app/src/main/java/com/enderthor/trainerbridgeble/MonitorActivity.kt Uses elapsedRealtime() for UI staleness calculations to tolerate clock changes.
app/src/main/java/com/enderthor/trainerbridgeble/FileLog.kt Increases log retention to keep longer ride sequences available for diagnosis.
app/src/main/java/com/enderthor/trainerbridgeble/correction/ErgBias.kt Implements ERG overshoot learning logic independent of Android clocking.
app/src/main/java/com/enderthor/trainerbridgeble/BridgeService.kt Adds receive/emit ownership tokens, moves staleness timing to elapsedRealtime(), improves wake-lock/snapshot/logging, and rate-limits ERG bias persistence.
app/src/main/java/com/enderthor/trainerbridgeble/ble/ZycleClient.kt Reworks connection/operation lifecycle with ownership tokens and GATT-session retirement on timeouts; improves scan mode policy and heartbeat timing.
app/src/main/java/com/enderthor/trainerbridgeble/ble/MirrorServer.kt Adds per-attempt advertising callbacks with orphan sweeping/backoff and retries GATT server open with backoff; hardens against late callbacks after stop.
app/src/main/java/com/enderthor/trainerbridgeble/ant/RawAntLink.kt Switches heartbeat timing to elapsedRealtime() to avoid wall-clock jump issues.
.gitignore Ignores local notes directory.
Review details
  • Files reviewed: 10/11 changed files
  • Comments generated: 2
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Config(this).let { c ->
// via PackageManager rather than BuildConfig: AGP 8 does not generate that class unless
// buildFeatures.buildConfig is turned on, and one log line does not justify a build change.
val ver = runCatching { packageManager.getPackageInfo(packageName, 0).versionName }.getOrNull()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Arreglado en d364b94: ?: "?". La cabecera de sesión es diagnóstico de tirada, y vnull era justo el caso en que más falta hace saber la versión.

Comment on lines +77 to +80
assertNull(emitOwner.clear().let { null })

emitOwner.clear() // stopEmit()
emitOwner.replace(Any()) // startEmit() with a replacement source

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Correcto, y era peor que un no-op: el comentario afirmaba que comprobaba que clear() no espera al dispatch. No comprobaba nada. Arreglado en d364b94 con la propiedad real — assertEquals(oldToken, emitOwner.clear()) — y fuera el clear() redundante. Que el lambda de producción despache fuera del monitor es una forma de llamada que ningún test JVM de este suite alcanza; eso ya está declarado como hueco 3 en la KDoc de la clase, que es su sitio honesto.

…ader

The emit-ownership test asserted `emitOwner.clear().let { null }` is null, which
is true by construction and can never fail — with a comment claiming it checked
that clear() does not wait on the dispatch. It checked nothing. Replaced with the
real property: clear() hands back exactly the token it retired. The claim about
the production call shape already lives in the class KDoc's gap list, which is
the honest place for something no JVM test here can reach.

versionName is nullable and getPackageInfo can throw, so an unresolved version
was logging as "vnull" in the session header.
… what each fix broke

The gate that admits FTMS procedures had no liveness property. An unanswered or
opcode-mismatched Control Point response left `pending` set forever, after which
every request — Request Control included — was refused for the rest of the ride.
Power kept streaming, so nothing looked broken; ERG just stopped obeying.

FtmsControlCoordinator now owns the three things every decision needs together:
the per-connection identities, the admitted procedure, and its command bytes.
They were in three places under two locks, which is where the bugs lived:

- Identity lookup and admission were separate steps, so a disconnect landing
  between them could promote a client that had already gone — and lock out its
  own reconnect with CONTROL_NOT_PERMITTED for the session.
- The command payload sat in a side slot, so one procedure's response could
  commit another's target, and the local button carried no payload at all —
  meaning ErgBias never saw opcode 0x04 and never retired an armed ERG target
  when the rider took manual control.
- A rebuild of the local GATT server cleared six pieces of state but not the one
  gating ERG. Closing a server raises no disconnect callbacks, so ownership
  survived onto a generation that could never come back.

Transport is no longer treated as acceptance. An ATT write the trainer ACKs can
still be refused by the procedure, so UI, servo filtering and ERG bias learning
now commit only on an FTMS Response Code of SUCCESS, with the bytes that
procedure actually carried. Learning from targets the machine rejected is how a
bias gets persisted against a trainer that was never holding it.

Each procedure has a 30 s deadline, armed after the trainer takes the write —
not at admission, which also measured our own serialised op queue. Expiry
quarantines and recycles the link, because an opcode-only response arriving late
can no longer be matched to a request. Arming and cancelling are both identity
checked: the local path crosses two main-loop hops, so a stale arm would
otherwise strip a live procedure of its only timer.

Also here: addService publishes its claim before the binder call rather than
after, where an early callback was discarded as stale and cost an 8 s watchdog
plus a full rebuild; a rebuild stops advertising instead of broadcasting over a
closed server; a refused or asynchronously failed terminal indication releases
the owner so the next requester can arbitrate; bootstrap honours
setCharacteristicNotification and rejects an FTMS Feature too short to carry
Target Setting Features.

The log can now prove all of it: admitted/rejected/response/timeout with opcode,
result and procedure id; every relayed write with its sequence and terminal
outcome, so an ordering violation is visible rather than inferred; and wakelock
state in the minute snapshot. Diagnostic logging is still off by default and is
only read at foreground start.

78 unit tests. Three adversarial review rounds got us here, and each of the
first two shipped fixes that introduced a fresh variant of the same lockout —
so the tests that matter are mutation checked against the exact line they name.
Held from master-on to master-off, the partial lock blocked CPU suspend for as
long as the switch was left on with no trainer in the room. onTimeout would
never save us — that is the Android 14 foreground-service timeout and the Karoo
is API 32 — so the only release was the user remembering.

So it follows the trainer link again, as it did before, but with 45 minutes of
slack: a mechanical stop, a phone call or a bathroom break must not cost the
lock mid-session, and a trainer drop mid-ride still recovers with it held.

Release is gated on a scan actually being registered with the controller. That
is the invariant the whole-session hold dropped: postDelayed does not wake a
suspended CPU, so releasing with no scan up leaves the trainer's advertising
with nothing to arrive at. Once a scan is up the controller wakes us on a
match, and the lock is retaken on STATE_CONNECTED — before discoverServices, so
the whole cold bootstrap runs awake.

A trainer seen but not connectable — the status=133 flap — now refreshes the
timer too. It was counting as an absent trainer and dropping the lock exactly
when the retry chain most needed the CPU running on schedule.

45 and not 30 because Android 12 silently downgrades a long-running scan to
opportunistic at 30 minutes, without onScanFailed, so our own scanning flag
keeps saying true. Colliding with that boundary would make a screen-off test
unable to tell the two apart. The downgrade itself is untouched and still
there: nothing restarts a scan that is already running.

The lock's log lines now say why they fired and report what actually happened,
instead of claiming a release before attempting it and calling the master
inactive while it was on.
Bestcycling asks for control before it subscribes to the Control Point — in
fact it never subscribes at all, it drives blind and never reads a response.
The undeliverable-result handling treated that as "this client will never hear
us, drop its claim", so control was granted and revoked in the same
millisecond and every command it sent afterwards came back Control Not
Permitted. ERG was dead from the first second of a session.

A client that has not subscribed is not gone. It is connected and can still
drive the trainer; it just cannot hear the answer. Releasing its claim turned
a lost indication into no control at all.

So the subscription cases only log now, and the claim stands. Releasing is
kept for a client that is genuinely gone — stale generation, device absent,
server closed — or an indication the stack actually refused.

Found on the trainer bridge with a real FTMS client after six review passes
had gone over this code. Every one of them asked whether ownership could get
stuck; none asked whether it could be dropped when it shouldn't be.
The Karoo shuts itself down ten minutes after the screen sleeps when no ride
is recording. Not Android's doze — Hammerhead's own state manager arms it the
moment the screen goes off, and it happens at any battery level; it took the
device down at 99% with the log cut mid-line. A foreground service does not
stop it, nor a partial wake lock, nor the battery whitelist: all three were
held at the moment it powered off. The delay lives in a system property that
is not writable without root. Recording a ride prevents it, which is no answer
for someone who does not want to record.

The SDK turns out to expose TurnScreenOn, and logcat confirms it lands in the
same state manager, on the same channel that armed the shutdown. So: dispatch
it every five minutes while a session is live, and the countdown never
completes. Five and not eight because a single missed tick would otherwise eat
the whole margin against ten; a dispatch the host does not take is retried in
twenty seconds rather than at the next slot.

It stops on the same leash as the wake lock. The idle guard releases that lock
after forty-five minutes with no trainer so an abandoned session can sleep, and
keeping the whole device awake past that point would defeat it with a more
expensive resource — a screen instead of a CPU. It also skips a recorded ride,
where the shutdown cannot happen anyway and the only effect would be relighting
a display the rider let sleep, and skips whenever the screen is already on,
where there is no countdown to disarm.

Verified on battery with the cable out: the screen slept at 54 seconds, so the
shutdown was due at 10:54. It ran past thirteen minutes — same pid, no restart,
no shutdown line, eight pokes all accepted, and every state line exactly sixty
seconds apart. The screen was seen waking on its own twice.

Alongside it: an optional flag to hold the monitor screen on, which only
applies while that screen is open and now says so; a shutdown receiver, so the
next time this happens the log explains its own gap instead of stopping
mid-sentence; and the mirror now says when a trainer value had nobody to go to,
which until now was indistinguishable from never having arrived.
…tdown

The skip trusts isInteractive to mean "no countdown is armed". It is only a
proxy, and AOSP counts a dreaming screensaver state as interactive — so a
firmware that armed the countdown in a state reading interactive would skip
every poke forever. That is the one error the five-against-ten-minute margin
cannot absorb, because it persists instead of passing. One skip is now the
limit. Being wrong in this direction is free: a poke while the screen really
is on does nothing.

Also: the comment above applyKeepAwake still promised an immediate poke it no
longer makes, which is the same defect this feature's own log lines were just
fixed for, one layer up.

The README now says what the Karoo does, where a rider meets it: it powers
itself off ten minutes after the screen sleeps with no ride recording, and
that looks exactly like the bridge crashing. Both new switches are documented
with the limit that matters — the wake costs battery, and holding the screen
on stops applying the moment you leave that screen.
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.

2 participants