Stop Bluetooth discovery when the panel closes (#6794)

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 <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-08-13 10:35:22 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent f075a789f4
commit 457aec6a8c
2 changed files with 119 additions and 1 deletions
+89 -1
View File
@@ -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 {