# The Headphones Connected, the Mouse Didn't

Source: https://gofranz.com/blog/the-headphones-connected-the-mouse-didnt/

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

```bash
$ 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

```bash
$ 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`.

```bash
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:

```bash
$ 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:

```json
/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:

```bash
$ 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:

```scheme
(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

1. `bluetoothctl show | grep Discovering`. If that says `yes` and you didn't ask for it, you've found it.
2. Run the loop above to get your candidates.
3. Quit them one at a time. Don't bother restarting `bluetooth` first, like I did - a live client just re-arms the scan.
4. 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.
5. `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.
