From 457aec6a8c0bac9e16ab7fccbd787c61cb79be48 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 13 Aug 2026 10:35:22 +0200 Subject: [PATCH] Stop Bluetooth discovery when the panel closes (#6794) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The discovery retry timer turned adapter.discovering on every second while the panel was open, and nothing ever turned it off. The BlueZ discovery session behind it is held by quickshell's D-Bus connection, so one visit to the panel left the radio in inquiry until the next shell restart — continuously starving A2DP audio on the same controller into stuttering, and 'bluetoothctl show' kept reporting 'Discovering: yes' long after the panel was gone. The panel now tracks the StopDiscovery it owes BlueZ and settles it once closed. A timer bound to the confirmed discovery state does the stopping, rather than a write in the close handler: quickshell only forwards a discovering write that differs from the last state BlueZ reported, so a stop issued while a just-fired StartDiscovery is still awaiting confirmation would be swallowed and leak the session. Binding to adapter.discovering re-arms the stop whenever the confirmation lands, a reopen inside the first interval keeps the scan running uninterrupted, and attempts are bounded so a session another BlueZ client holds up cannot draw StopDiscovery calls forever. One widget instance exists per monitor and they all share the default adapter — the same shared-backend shape the network panel's wifi scanner fix (#6772) dealt with — so the debt follows the session: an instance opening onto a running scan adopts it, a closing instance hands it to a panel still open on another monitor (the popout handoff closes one instance as it opens the next), and a destroyed instance passes it to a surviving sibling. Fixes #6789 Co-authored-by: Claude Fable 5 --- shell/plugins/panels/bluetooth/Panel.qml | 90 +++++++++++++++++++++++- test/shell.d/bluetooth-test.sh | 30 ++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) diff --git a/shell/plugins/panels/bluetooth/Panel.qml b/shell/plugins/panels/bluetooth/Panel.qml index 0cda97ef..343357b4 100644 --- a/shell/plugins/panels/bluetooth/Panel.qml +++ b/shell/plugins/panels/bluetooth/Panel.qml @@ -22,6 +22,13 @@ Panel { property var pendingActions: ({}) readonly property var adapter: Bluetooth.defaultAdapter + + // True while this instance owes BlueZ a StopDiscovery: set when it starts + // discovery (or opens onto a session already running) and cleared once + // discovery is confirmed down after close. Ownership, not state — BlueZ's + // Discovering property also reflects sessions other clients hold, which are + // never this panel's to stop. + property bool owesDiscoveryStop: false readonly property var devices: Bluetooth.devices ? Bluetooth.devices.values : [] readonly property var pipewireNodes: Pipewire.nodes ? Pipewire.nodes.values : [] property var pendingAudioOutputDevice: null @@ -400,6 +407,10 @@ Panel { onOpenedChanged: { if (opened) { + // Adopt a discovery session that is already running — a popout handoff + // from another monitor, or one leaked by an instance that could not + // finish its own stop — so this close settles it either way. + if (adapter !== null && adapter.discovering) owesDiscoveryStop = true if (connectedDevices.length > 0) { focusSection = "connected"; selectedIndex = 0 } else if (knownDevices.length > 0) { focusSection = "known"; selectedIndex = 0 } else if (discoveredDevices.length > 0) { focusSection = "discovered"; selectedIndex = 0 } @@ -409,6 +420,19 @@ Panel { } } + // Another per-monitor instance of this widget whose panel is open, if any. + // All instances share the default adapter, and switching the popout to a + // different monitor closes one instance as it opens the next, so the + // closing side has to leave the scan alone for the side still on screen. + function openSibling() { + if (!bar || typeof bar.moduleWidgets !== "function") return null + var items = bar.moduleWidgets(moduleName) + for (var i = 0; i < items.length; i++) { + if (items[i] && items[i] !== root && items[i].opened === true) return items[i] + } + return null + } + function updateFocusedAddress() { var d = deviceAt(focusSection, selectedIndex) focusedDeviceAddress = d ? (d.address || "") : "" @@ -486,7 +510,71 @@ Panel { repeat: true triggeredOnStart: true running: root.opened && root.adapter !== null && root.adapter.enabled && !root.adapter.discovering - onTriggered: root.adapter.discovering = true + onTriggered: { + root.owesDiscoveryStop = true + root.adapter.discovering = true + } + } + + // The way back down. The BlueZ discovery session behind adapter.discovering + // is held by quickshell's D-Bus connection, so nothing ends it at close: + // without this timer, one visit to the panel left the radio in inquiry + // until the next shell restart, starving A2DP audio on the same controller + // into stutters. + // + // A timer bound to the confirmed state rather than a write at close time: + // quickshell only forwards a discovering write that differs from the last + // state BlueZ reported, so a stop issued while a just-fired StartDiscovery + // is still awaiting confirmation would be swallowed and leak the session. + // Binding to adapter.discovering means a confirmation landing at any point + // after close re-arms the stop, and a reopen inside the first interval + // keeps the scan running uninterrupted. Attempts are bounded so a session + // some other BlueZ client keeps up cannot draw StopDiscovery fire forever. + Timer { + id: discoveryStop + interval: 1000 + repeat: true + property int attempts: 0 + running: !root.opened && root.owesDiscoveryStop && root.adapter !== null && root.adapter.discovering === true + onRunningChanged: if (running) attempts = 0 + onTriggered: { + // The scan now serves the open panel, so the debt moves with it — B may + // have opened before BlueZ confirmed A's start, in which case B's own + // open-time adoption saw nothing to adopt. + var sibling = root.openSibling() + if (sibling) { + sibling.owesDiscoveryStop = true + root.owesDiscoveryStop = false + return + } + attempts += 1 + if (attempts > 3) { root.owesDiscoveryStop = false; return } + root.adapter.discovering = false + } + } + + // The debt is settled the moment BlueZ reports discovery down — whether + // because the stop above landed or the session ended some other way — so a + // stale claim never touches a scan another client starts later. While the + // panel is open, discoveryRetry re-incurs it as it restarts the scan. + Connections { + target: root.adapter + function onDiscoveringChanged() { + if (!root.adapter.discovering) root.owesDiscoveryStop = false + } + } + + // A destroyed instance cannot wait for BlueZ confirmations, so it hands any + // debt to a surviving sibling — whose declarative stop catches even a start + // confirmed after this object is gone — and only writes the stop directly + // when it is the last one standing. + Component.onDestruction: { + if (!owesDiscoveryStop) return + var items = bar && typeof bar.moduleWidgets === "function" ? bar.moduleWidgets(moduleName) : [] + for (var i = 0; i < items.length; i++) { + if (items[i] && items[i] !== root) { items[i].owesDiscoveryStop = true; return } + } + if (adapter !== null && adapter.discovering) adapter.discovering = false } Timer { diff --git a/test/shell.d/bluetooth-test.sh b/test/shell.d/bluetooth-test.sh index d66b8083..b20727e6 100644 --- a/test/shell.d/bluetooth-test.sh +++ b/test/shell.d/bluetooth-test.sh @@ -20,6 +20,36 @@ assert(/manageIpc: false/.test(panelSource), 'bluetooth owns its IPC handler so assert(/function toggleBluetooth\(\)[\s\S]*?execDetached\(\["omarchy-bluetooth-power", adapter\.enabled \? "off" : "on"\]\)/.test(panelSource), 'bluetooth toggles the radio through the rfkill soft block') assert(!/adapter\.enabled = /.test(panelSource), 'bluetooth never writes the adapter power state directly') +// Discovery is a BlueZ session that nothing ends at panel close: it persists +// until StopDiscovery or until quickshell's D-Bus connection drops with the +// shell, and a leaked session keeps the radio in inquiry, starving A2DP audio +// on the same controller. The panel tracks the stop it owes and settles it +// once closed. +const retryTimer = panelSource.match(/id: discoveryRetry[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/) +assert(retryTimer, 'bluetooth has the discovery retry timer') +assert(/owesDiscoveryStop = true/.test(retryTimer[0]), 'bluetooth takes on the stop it owes when it starts discovery') + +// Quickshell only forwards a discovering write that differs from BlueZ's last +// confirmed state, so a stop written in the same instant as an in-flight +// StartDiscovery would be swallowed. Binding the stop timer to the confirmed +// state means a confirmation landing at any point after close re-arms it. +const stopTimer = panelSource.match(/id: discoveryStop[\s\S]*?onTriggered: \{[\s\S]*?\n {4}\}/) +assert(stopTimer, 'bluetooth has the discovery stop timer') +assert(/running: !root\.opened && root\.owesDiscoveryStop[\s\S]*discovering === true/.test(stopTimer[0]), 'bluetooth arms the stop off the confirmed discovery state while closed') +assert(/discovering = false/.test(stopTimer[0]), 'bluetooth stops discovery after the panel closes') + +// One widget instance exists per monitor and they share the default adapter, +// so a closing instance hands the scan to a panel still open on another +// monitor instead of stopping it — that is the popout handoff between +// monitors. +assert(/function openSibling\(\)/.test(panelSource), 'bluetooth can see panel instances on other monitors') +assert(/sibling\.owesDiscoveryStop = true/.test(stopTimer[0]), 'bluetooth moves the stop it owes to an open panel on another monitor instead of stopping its scan') + +// The debt clears when BlueZ confirms discovery down, and a destroyed +// instance hands it to a surviving sibling instead of taking it to the grave. +assert(/onDiscoveringChanged[\s\S]{0,120}owesDiscoveryStop = false/.test(panelSource), 'bluetooth settles the stop it owes once discovery is confirmed down') +assert(/Component\.onDestruction: \{[\s\S]{0,400}owesDiscoveryStop = true[\s\S]{0,200}discovering = false/.test(panelSource), 'bluetooth passes the stop it owes to a sibling when an instance is destroyed') + assert(bluetooth.isUuidLike('0000110b-0000-1000-8000-00805f9b34fb'), 'bluetooth detects UUID-like names') assert(bluetooth.isAddressLike('AA:BB:CC:DD:EE:FF'), 'bluetooth detects address-like names') assertEqual(bluetooth.normalizedAddress('AA:BB_CC-dd-ee-ff'), 'aabbccddeeff', 'bluetooth normalizes BlueZ and PipeWire address formats')