Skip to content

core: stop the AACP reader spinning when the peer tears the channel down - #875

Open
jevonlipsey wants to merge 1 commit into
librepods-org:android/rewritefrom
jevonlipsey:fix/aacp-reader-spin-on-remote-teardown
Open

jevonlipsey wants to merge 1 commit into
librepods-org:android/rewritefrom
jevonlipsey:fix/aacp-reader-spin-on-remote-teardown

Conversation

@jevonlipsey

@jevonlipsey jevonlipsey commented Oct 27, 2026 •

Copy link
Copy Markdown

Problem

AACPManager.connect() starts a reader coroutine that loops on while (socket.isConnected). Two of its three exit paths did not exit:

} else if (bytesRead == -1) {
    Log.i("AirPodsService", "socket closed (bytesRead = -1)")   // no break
}
} catch (e: Exception) {
    Log.i(TAG, "Error reading data, we have probably disconnected.")  // no break
    e.printStackTrace()
}

BluetoothSocket.isConnected only becomes false once we close the socket. AACPManager.disconnect() does, but a peer-initiated teardown never calls it. So when another device takes the AirPods, read() returns -1 immediately and forever, the -1 branch logs and falls straight back into the loop, and the loop becomes an unbounded busy loop emitting one log line per iteration. It only ends when the process dies.

The reader Job was also never retained, so every reconnect started another reader and the already-spinning ones were never reaped.

Measured impact

Retroid Pocket Nova (SM8550, Android 13), v2.0.0-alpha.4, device idle on a desk with AirPods shared between a Mac, an iPhone and the handheld:

app CPU 226%, peaking ~370% as readers stacked
sustained ~112% of one core across a 1h38m foreground session
log output 486,443,761 entries — ~9,760 lines/s, ~77 bytes each (37.5 GB since boot)
logd 146% (the per-iteration log write is the loop's own bottleneck)
hottest SoC zone 93.5 °C on an idle device
after force-stop 93.5 °C → 45.3 °C, logd 146% → 12%

dumpsys batterystats shows the trigger: repeated ACL_CONNECTED/ACL_DISCONNECTED broadcasts throughout the session, including a disconnect 1.4 s after the preceding connect. Each handoff away from the device tears the AACP channel down and starts another spinning reader, which is why CPU climbed with the number of handoffs.

This is not device- or vendor-specific — it will spin on any Android device whose AirPods are disconnected remotely. It is just much easier to hit when several Apple devices are competing for the same buds.

Fix

  • break on bytesRead == -1 and on a failed read. Both are terminal for a Bluetooth socket.
  • Keep the per-packet catch non-fatal, so one malformed packet still does not drop the connection.
  • Retain the reader and init Jobs and cancel them in connect() and disconnect(), so readers cannot stack.

Deliberately left out: notifying the device of a peer-initiated teardown from inside the reader. That is a behaviour change rather than a bug fix, and recovery already works — the next ACL_CONNECTED calls connect(), which now cancels the dead reader first.

Testing

Built as fossRelease against compileSdk 37 (AGP 9.4.1 / Gradle 9.8.1) and installed on the affected device (Retroid Pocket Nova, SM8550, Android 13, arm64).

Verified against real handoffs: the EOF line now appears once and the loop exits in the same millisecond, where before it was the flood. Process CPU over a 21-minute window containing two connects and one peer teardown was 12 cpu-seconds / 1250 s alive = 1.00% of one core, against ~112% sustained before. No reader stacking, and nothing burning in per-thread sampling. Full timeline in the comment below.

The reader coroutine started by AACPManager.connect() loops on
`while (socket.isConnected)`. Two of its three exit paths did not exit:

  - `bytesRead == -1` (EOF) logged and fell back into the loop.
  - a read exception logged, printed a stack trace, and fell back into
    the loop.

`BluetoothSocket.isConnected` only goes false once *we* close the socket,
and a remote teardown never calls disconnect(). So when the peer drops the
channel - which on AirPods happens every time another device takes them -
read() returns -1 immediately and forever, and the loop becomes an
unbounded busy loop emitting one log line per iteration.

Measured on a Retroid Pocket Nova (SM8550, Android 13) with v2.0.0-alpha.4:
~112% of one core sustained across a 1h38m foreground session, 486M log
entries (~9.7k lines/s, ~77 bytes each), logd pinned at 146%, and the SoC
held at 93.5 C on an idle device until the app was force-stopped.

The reader Job was also never retained, so each reconnect started another
reader and the already-spinning ones were never reaped. CPU therefore grew
with the number of handoffs; samples during one session read 226% and ~370%.

Fix:
  - break out of the loop on EOF and on a failed read. Both are terminal
    for a Bluetooth socket.
  - keep retaining the per-packet catch, so one malformed packet still does
    not take the connection down.
  - hold the reader and init Jobs and cancel them in connect() and
    disconnect(), so readers cannot stack up.

Deliberately left out: notifying the device of a peer-initiated teardown
from the reader. That is a behaviour change, and recovery already works -
the next ACL_CONNECTED calls connect(), which now reaps the dead reader.
@jevonlipsey

Copy link
Copy Markdown
Author

Device-verified now, on the hardware the measurements in the description came from (Retroid Pocket Nova, SM8550, Android 13, arm64-v8a).

Provoked real handoffs by connecting the AirPods to the handheld and then taking them back with another Apple device. The reader now terminates on the peer teardown instead of spinning:

13:11:43.013  AACPManager[..]: starting to read data...
13:11:57.806  AACPManager[..]: socket closed by peer (bytesRead = -1)
13:11:57.806  AACPManager[..]: stopped reading data
13:11:57.817  ACL_DISCONNECTED propagates
13:13:12.327  AACPManager[..]: starting to read data...      <- reconnect, clean

The EOF line appears once and the loop exits in the same millisecond. Before the change that same line was the flood — ~9,760/s until the process was killed.

Over a 21-minute window containing two connects and one peer teardown:

before after
process CPU ~112% of one core, sustained 12 cpu-seconds / 1250 s alive = 1.00%
capture size ~770 KB/s 7 MB total (~56 lines/s, all sources)
occurrences of the EOF log line millions 1
hottest SoC zone 93.5 °C ~53 °C

Also confirmed no reader stacking: two starting to read data and one stopped reading data, with one socket still live — so the retained Job is being cancelled as intended. Per-thread sampling shows nothing burning (top thread is the JIT pool at 7 s, from startup).

Build note, in case it helps anyone reproducing: the fix is entirely in :core, which has no externalNativeBuild, so it can be built and tested without the NDK by reusing the published release APK's arm64-v8a libfluoride_hooks.so and libhiddenapi.so as jniLibs.

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