The Headphones Connected, the Mouse Didn't
My mouse and keyboard stopped reconnecting overnight while my headphones were fine. The adapter was stuck in a permanent LE scan that Chrome opens on launch - here's how I found it and the policy file that stops it.
My laptop had been running all night. In the morning the mouse and the keyboard were both blinking away, the way they do when they can’t find the machine they’re paired to. The Nothing headphones connected the moment I took them out of the case.
Something had left the adapter running a permanent LE scan, and an active scan stops the host initiating LE connections - so the mouse and keyboard could advertise all they liked and nothing would ever answer. The headphones speak BR/EDR, classic Bluetooth, which pages the host instead. That path was never touched.
The first look
$ bluetoothctl show | grep -E 'Powered|Discovering'
Powered: yes
Discovering: yes
Discovering: yes, nothing connected, on a machine I hadn’t asked to scan anything. The device cache had about fifty entries with random addresses - 65:89:74:04:66:FA, 7D:BF:0F:DB:74:1C - which is what a long-running scan in a dense apartment block looks like.
bluetoothctl info on each of my three sorted them out:
| device | transport |
|---|---|
| MX Anywhere 2S | LE only |
| Air75 BT5.0 | LE only |
| Nothing Ear | BR/EDR and LE |
The one device that kept working was the only one with a classic radio path. connect on either of the LE devices hung - no error, no refusal, twenty seconds and nothing came back.
Not a wedged controller
$ sudo herd restart bluetooth
$ bluetoothctl power off && bluetoothctl power on
$ bluetoothctl show | grep Discovering
Discovering: yes
The scan was back within seconds, both times. That’s the useful result even though it fixed nothing: a stale session survives neither, and a power cycle clears adapter state outright. Something alive was re-arming the scan the moment the adapter came back.
Worth knowing on Guix: hciconfig, hcitool and btmgmt aren’t there to reach for. They’re deprecated upstream and Guix doesn’t compile them, so bluetoothctl power and rfkill are what you get.
Finding the client
BlueZ doesn’t expose which D-Bus client holds a discovery session, and dbus-monitor gets refused BecomeMonitor on this box. So work backwards: list every connection on the system bus, resolve each to a PID, and check whether that binary even knows the word StartDiscovery.
for n in $(dbus-send --system --dest=org.freedesktop.DBus --print-reply \
/org/freedesktop/DBus org.freedesktop.DBus.ListNames |
grep -oE '":[0-9.]+"' | tr -d '"'); do
pid=$(dbus-send --system --dest=org.freedesktop.DBus --print-reply \
/org/freedesktop/DBus org.freedesktop.DBus.GetConnectionUnixProcessID \
string:"$n" | awk '/uint32/{print $2}')
exe=$(readlink -f /proc/$pid/exe 2>/dev/null) || continue
grep -aq StartDiscovery "$exe" 2>/dev/null && echo "$pid $(basename "$exe")"
done | sort -u
Two hits worth anything: chrome and slack, both Chromium underneath. Waybar came back clean, despite the Bluetooth module in my bar. One gotcha: bluetoothd matches too, since it’s the process providing StartDiscovery. Filter it out or you’ll chase your own daemon.
From there it’s a bisect. Quit Slack: still scanning. Quit Chrome:
$ bluetoothctl show | grep Discovering
Discovering: no
$ bluetoothctl devices Connected
Device E2:7D:F3:EB:50:11 Air75 BT5.0
Device FD:B6:AB:99:E1:22 MX Anywhere 2S
Both devices back, immediately, without touching the adapter.
Starting Chrome again brought the scan straight back, but the mouse and keyboard stayed connected - which explains the timing. The scan doesn’t break a live link, it only blocks a new one from being established. It bites after the peripherals idle out on their own, at which point they’re stuck advertising into a host that can’t answer. Hence “broken overnight”.
Dead ends
A stored Web Bluetooth grant for banglejs.com - a watch I last touched six months ago - looked like the answer. Revoked it; Chrome carried on scanning. Extensions were the next guess, since three of mine bundle Web Bluetooth code for Ledger hardware wallets and a background service worker shows up in no tab and no manifest permission; --disable-extensions scanned too. No site, no extension, no open tab, which left Chrome itself.
The fix
Turn Web Bluetooth off at the policy layer. On Linux that’s one JSON file and no admin infrastructure:
/etc/opt/chrome/policies/managed/bluetooth.json
{ "DefaultWebBluetoothGuardSetting": 2 }
The path is easy to fluff - it’s /etc/opt/chrome/policies/managed, and I first created it without the chrome component, where nothing would ever read it. Root-owned and 0644 is what you want; the directory must not be writable by your own user, or anything running as you can set your browser policy for you.
Full quit, relaunch, and:
$ bluetoothctl show | grep Discovering
Discovering: no
Chrome running, nothing scanning, all three devices connected. So it was Web Bluetooth after all, with nothing anywhere asking for it: Chrome opens the session on launch and holds it for as long as it runs. Why it does that when no site has requested Bluetooth, I still can’t tell you.
On Guix
The file is declared rather than typed into /etc by hand:
(simple-service 'chrome-web-bluetooth-policy etc-service-type
(list `("opt/chrome/policies/managed/bluetooth.json"
,(plain-file "chrome-bluetooth-policy.json"
"{ \"DefaultWebBluetoothGuardSetting\": 2 }\n"))))
Two things to check before trusting that. etc-service-type symlinks top-level /etc entries, so /etc/opt becomes a link into the store - meaning the real /etc/opt I’d made by hand has to go first. activate-etc does rm-f then symlink, rm-f can’t delete a non-empty directory, and the symlink fails with EEXIST. Reconfigure dies, and the error doesn’t obviously point at the cause.
The second is whether Chrome accepts the file at all. guix-daemon runs unprivileged here, so store files are owned by the daemon rather than root, and that trap already bites two other things in my config: auditd refuses a config it doesn’t see as root-owned, and pam_u2f refuses a non-root authfile silently. Chrome doesn’t check. config_dir_policy_loader.cc enumerates the directory and deserializes whatever JSON it finds, with no ownership, permission or symlink check anywhere, and FileEnumerator uses stat() rather than lstat() unless you ask for SHOW_SYM_LINKS, so the store symlinks enumerate as plain files.
If this is you
bluetoothctl show | grep Discovering. If that saysyesand you didn’t ask for it, you’ve found it.- Run the loop above to get your candidates.
- Quit them one at a time. Don’t bother restarting
bluetoothfirst, like I did - a live client just re-arms the scan. - If it’s Chrome, drop in the policy file. Quitting the browser every time your keyboard falls off is a workaround; the policy is the fix.
bluetoothctl trust <mac>on your input devices. Mine were untrusted, and with no Bluetooth agent running there’s nothing around to authorise an untrusted device’s reconnect. That wasn’t this bug, but it’s a second way to end up in the same place.
When one Bluetooth device works and another doesn’t, check what transport each is using before you start suspecting the radio. Mine was fine the entire time.