Skip to content

fix(linux): duty-cycle BLE scan so other bonded LE devices can reconnect - #750

Open
pedrosekine wants to merge 1 commit into
librepods-org:mainfrom
pedrosekine:fix/ble-scan-duty-cycle
Open

pedrosekine wants to merge 1 commit into
librepods-org:mainfrom
pedrosekine:fix/ble-scan-duty-cycle

Conversation

@pedrosekine

Copy link
Copy Markdown

Problem

BleManager sets setLowEnergyDiscoveryTimeout(0) ("continuous scanning"), so the BlueZ discovery session stays open for as long as scanning is wanted — which is the entire time the AirPods are not connected (initializeBluetooth(), and again on disconnect).

The Linux kernel will not re-arm the passive accept-list scan while any discovery session is active — hci_update_passive_scan_sync() returns early unless discovery.state == DISCOVERY_STOPPED. That passive scan is precisely the mechanism that reconnects bonded LE peripherals.

The result: while librepods is scanning, no other bonded LE device on the machine can auto-reconnect. On my laptop, an MX Master 3 mouse and MX Keys keyboard silently stopped reconnecting after suspend and had to be connected by hand every time. With the AirPods left in their case overnight, the adapter sat at Discovering: yes for 13 hours straight.

It's an easy one to misattribute — it looks like a mouse, BlueZ, or kernel problem, and nothing points at the AirPods daemon.

Reproducing

$ bluetoothctl show | grep Discovering
        Discovering: yes      # stuck on, with no scan running anywhere

Stop librepods and a bonded LE mouse reconnects on its own within seconds.

Fix

Scan in bounded 4s windows with an 8s idle gap. The idle gap lets BlueZ drop the discovery session so the kernel can re-arm passive scanning; the next window re-arms ours.

# after
08:45:31 Discovering=yes    08:45:43 Discovering=yes
08:45:34 Discovering=no     08:45:46 Discovering=no

This also makes onScanFinished() reachable. With a 0 timeout the agent never emitted finished(), and the restart inside it was guarded by if (discoveryAgent->isActive()) — a condition that cannot hold in a finished() handler, so the branch was dead regardless.

isScanning() now also reports true across the idle gap, since its one caller (onSystemGoingToSleep) means "are we in a scanning state", not "is the radio scanning this instant".

Detection coverage

Unchanged. On my adapter a single 4s window sees the same 11 Apple manufacturer-data advertisers that a continuous scan does — BLE advertisers broadcast every 20ms–2s, so a 4s window is far longer than needed. The cost is up to 8s of extra latency before an advertising device is first noticed. Once connected, everything runs over the AAP L2CAP link and is untouched by this change.

Happy to make the two constants configurable, or to tune them, if you'd prefer different values.

Testing

On Arch Linux, BlueZ 5.87, Qt 6, MediaTek MT7922 adapter, AirPods Pro:

  • ✅ Mouse and keyboard auto-reconnect across suspend/resume — the original bug, confirmed fixed
  • ✅ Duty cycle behaves as intended under the real systemd user service
  • ✅ 4s window sees the same advertiser set as a continuous scan
  • ✅ AAP path unaffected: noise-control commands round-trip (Transparency → ANC → Adaptive), ear detection parses correctly, media detection works
  • ⚠️ Not verified: the 0x07 proximity-pairing parse path. My AirPods never emitted a 0x07 advert during testing — not in 20s of continuous scanning, nor a 2-minute capture across case open/close cycles — so I had nothing to feed the parser. This patch doesn't touch that code, and a continuous scan saw no 0x07 either, so I don't believe it's affected. Flagging it rather than implying coverage I don't have.

Unrelated issues noticed

Not touched here, happy to file separately:

  1. The single-instance guard can never firemain() calls QLocalServer::removeServer("app_server") and deletes /tmp/app_server immediately before probing it with socket_check.connectToServer("app_server"), so the connect always fails.
  2. --headless isn't parsed — the arg loop only handles --debug and --hide, though the flag appears in the systemd unit in the README.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Bsha6PUDqP7yiNEMM8UG5a

BleManager set setLowEnergyDiscoveryTimeout(0), so the discovery session
stayed open for as long as the scan was wanted - which is the whole time
the AirPods are not connected.

The Linux kernel will not re-arm the passive accept-list scan while any
discovery session is active (hci_update_passive_scan_sync returns early
unless discovery.state == DISCOVERY_STOPPED). That passive scan is what
reconnects bonded LE peripherals, so while librepods was scanning, no
other bonded LE device on the machine could auto-reconnect at all. On my
laptop an MX Master 3 and MX Keys stopped reconnecting after suspend and
had to be connected by hand every time.

Scan in bounded 4s windows with an 8s idle gap instead. The idle gap lets
BlueZ drop the discovery session so the kernel can re-arm passive
scanning; the next window re-arms ours.

This also makes onScanFinished() reachable. With a 0 timeout the agent
never emitted finished(), and its restart was guarded by
`if (discoveryAgent->isActive())` - a condition that cannot hold in a
finished() handler, so the branch was dead either way.

Detection coverage is unchanged: on my adapter a single 4s window sees
the same 11 Apple manufacturer-data advertisers that a continuous scan
does, since advertisers broadcast every 20ms-2s. The cost is up to 8s of
extra latency before an advertising device is first noticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bsha6PUDqP7yiNEMM8UG5a
@gamaraan

Copy link
Copy Markdown

Second independent reproduction of this exact failure, captured at the daemon level — hoping it helps get this merged.

Setup: Arch Linux, BlueZ 5.87-2, Intel AX211 (btusb), MX Master 3S (bonded BLE), librepods running headless (vendored copy of the Linux daemon).

What we captured (boot-window bluetoothd -d log on a boot where the mouse never reconnected):

  • 08:46:00 — one second after bluetoothd starts, librepods (D-Bus :1.49) calls SetDiscoveryFilter + StartDiscovery. BlueZ sends MGMT_OP_START_SERVICE_DISCOVERY 127, 6, 0, and dutifully interleaves it: start_discovery_complete() status 0x00 roughly every 11s, indefinitely.
  • The mouse never reconnects: hci_update_passive_scan_sync() early-returns while discovery.state != DISCOVERY_STOPPED (kernel hci_sync.c), and the sub-second idle gaps between BlueZ interleave restarts are too small for auto-connect.
  • 08:47:38 — a watchdog power-cycles the adapter, which drops librepods' session; the kernel immediately re-arms the passive accept-list scan and the mouse connects 4s later.
  • 08:47:39 — librepods re-issues StartDiscovery (gets NotReady mid-cycle, retries, resumes). Left alone it would hold Discovering: yes forever, as on your 13h overnight capture.

So: any always-on discovery client permanently starves passive auto-connect of every other bonded LE peripheral on the machine. Here it reproduced on roughly every second boot, depending on whether the mouse advertised before librepods' session began.

Two confirmations of details in this PR:

  • With setLowEnergyDiscoveryTimeout(0), Qt never emits finished(), and the restart guard in onScanFinished() (if (discoveryAgent->isActive()) inside a finished() handler) is exactly as dead as described.
  • Debugging side-note for anyone investigating on BlueZ 5.87: a StopDiscovery call from a non-owner client always fails with org.bluez.Error.Failed: "No discovery started" even when the session is perfectly healthy — that symptom is just librepods legitimately holding its session, and is easy to misread as the genuine orphan-state bug in Lingering discovery mode when a client is killed requires daemon restart to recover the stack bluez/bluez#2547 (it is not the same thing).

Applying this diff to my local build now; will report back with results on the AX211 + AirPods Pro 2 combo.

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