Quickshell.Networking — Wi-Fi disconnect drives an unstoppable reconnect loop
Quickshell: 0.3.0 (Arch quickshell 0.3.0-1.1)
NetworkManager: (host system)
Compositor: Niri
Reporter context: refactor of a shell from nmcli-driven Wi-Fi to native Quickshell.Networking bindings. Everything except disconnect ended up working cleanly; disconnect is the one path that we could not get right via any combination of native calls.
Symptom
After any user-initiated Wi-Fi disconnect issued via the native API (WifiDevice.disconnect(), WifiNetwork.disconnect(), or WifiDevice.autoconnect = false), NetworkManager enters a tight reconnect retry loop that:
- Keeps the device "active" enough that the user has no internet (each attempt fails before completing, or attempts loop too quickly to settle).
- Does not stop when QML stops setting any properties.
- Does not stop when the device's scanner is disabled.
- Continues until Quickshell is restarted entirely.
- After QS restart, NM is quiescent and a manual connect via QS works.
The exact same Wi-Fi adapter, NM version, and user profile does not loop under our legacy nmcli-only implementation (single nmcli device disconnect <iface> call). Switching back to that path inside the QS shell instantly
restores correct behaviour.
So: NM itself is fine. The legacy nmcli call is fine. Something about keeping QS.Networking alive in the process while disconnect is issued causes the loop.
Reproduction (in our shell, but isolatable)
QML facade roughly:
property var _wifiDev: /* first WifiDevice from Networking.devices */
function disconnectCurrent() {
if (!_wifiDev) return
_wifiDev.autoconnect = false // tried in isolation: loop
_wifiDev.disconnect() // tried in isolation: loop
// Combination: also loops.
// Adding `_wifiDev.scannerEnabled = false`: also loops.
}
Click any disconnect button on the currently-connected SSID → loop.
QML never re-issues connect() during the loop (verified by
console.log on every connect entry point — zero re-entries).
A minimal test PanelWindow that imports Quickshell.Networking, picks the first WifiDevice, and exposes a button calling dev.disconnect() should reproduce on any NM box.
What we tried (all looped)
In our repo these are around 10 commits (2 separate tries) but in concept:
| Attempt |
Disconnect call |
Result |
| 1 |
WifiNetwork.disconnect() on the active network |
loop |
| 2 |
WifiDevice.disconnect() only |
loop |
| 3 |
WifiDevice.autoconnect = false, then WifiNetwork.disconnect() |
loop |
| 4 |
WifiDevice.autoconnect = false, then WifiDevice.disconnect() |
loop |
| 5 |
nmcli connection modify <active-uuid> autoconnect no then WifiDevice.disconnect() |
loop |
| 6 |
nmcli device disconnect <iface> (single call) with QS.Networking observers active |
loop |
| 7 |
WifiDevice.autoconnect = false + WifiDevice.scannerEnabled = false + nmcli device disconnect <iface> |
loop |
Same nmcli device disconnect <iface> call from the legacy shell (no import Quickshell.Networking, no Networking.devices observers): works.
Logged signals during the loop:
WifiDevice.autoconnectChanged fires exactly once (to false, the value we wrote). It is not flipped back to true by any external party.
- Our QML never calls
connect() / connectWithPsk / connectWithSettings again (zero log lines for those during the loop).
WifiDevice.connectedChanged fires repeatedly (state oscillates).
- The device's
scannerEnabled going false does not break the loop.
So the loop is NM-driven, but somehow only when QS.Networking is observing the device. We cannot reach the relevant NM property/method from the public QML API.
Hypothesis
Something in Quickshell.Networking's internal D-Bus observation either:
- Calls
Device.Reapply / re-activates the profile after the disconnect D-Bus call, or
- Keeps the device in a state where NM's "available connection" evaluator re-arms autoconnect on every scan completion, or
- Maps
WifiDevice.disconnect() to a D-Bus method that lacks the "explicit-disconnect" semantics that nmcli device disconnect carries (org.freedesktop.NetworkManager.Device.Disconnect should — but maybe the bus call is missing a flag or being followed by an activation).
We cannot tell from QML which is which.
What would unblock us
Any of:
- A flag / method on
WifiDevice that maps to NM's
Device.Disconnect with the explicit-disconnect bit set and is not followed by anything else.
- A documented "disconnect and stay disconnected" pattern for
Quickshell.Networking.
- Confirmation that the current
disconnect() implementation does carry explicit-disconnect, plus a way to diagnose what's actually re-arming autoconnect.
Until then we keep nmcli device disconnect <iface> as a single subprocess shell-out in services/WifiScanner.qml and use native bindings for everything else (device enumeration, scan list, connect, forget, ethernet, wifiEnabled).
Files in this repo
services/WifiScanner.qml — reverted to legacy nmcli implementation.
services/Network.qml — fully native.
modules/widgets/network/EthernetRow.qml — native.
modules/controlcenter/CCTogglesSection.qml — native.
Quickshell.Networking — Wi-Fi disconnect drives an unstoppable reconnect loop
Quickshell: 0.3.0 (Arch
quickshell 0.3.0-1.1)NetworkManager: (host system)
Compositor: Niri
Reporter context: refactor of a shell from
nmcli-driven Wi-Fi to nativeQuickshell.Networkingbindings. Everything except disconnect ended up working cleanly; disconnect is the one path that we could not get right via any combination of native calls.Symptom
After any user-initiated Wi-Fi disconnect issued via the native API (
WifiDevice.disconnect(),WifiNetwork.disconnect(), orWifiDevice.autoconnect = false), NetworkManager enters a tight reconnect retry loop that:The exact same Wi-Fi adapter, NM version, and user profile does not loop under our legacy
nmcli-only implementation (singlenmcli device disconnect <iface>call). Switching back to that path inside the QS shell instantlyrestores correct behaviour.
So: NM itself is fine. The legacy nmcli call is fine. Something about keeping QS.Networking alive in the process while disconnect is issued causes the loop.
Reproduction (in our shell, but isolatable)
QML facade roughly:
Click any disconnect button on the currently-connected SSID → loop.
QML never re-issues
connect()during the loop (verified byconsole.logon every connect entry point — zero re-entries).A minimal test PanelWindow that imports
Quickshell.Networking, picks the first WifiDevice, and exposes a button callingdev.disconnect()should reproduce on any NM box.What we tried (all looped)
In our repo these are around 10 commits (2 separate tries) but in concept:
WifiNetwork.disconnect()on the active networkWifiDevice.disconnect()onlyWifiDevice.autoconnect = false, thenWifiNetwork.disconnect()WifiDevice.autoconnect = false, thenWifiDevice.disconnect()nmcli connection modify <active-uuid> autoconnect nothenWifiDevice.disconnect()nmcli device disconnect <iface>(single call) with QS.Networking observers activeWifiDevice.autoconnect = false+WifiDevice.scannerEnabled = false+nmcli device disconnect <iface>Same
nmcli device disconnect <iface>call from the legacy shell (noimport Quickshell.Networking, noNetworking.devicesobservers): works.Logged signals during the loop:
WifiDevice.autoconnectChangedfires exactly once (tofalse, the value we wrote). It is not flipped back totrueby any external party.connect()/connectWithPsk/connectWithSettingsagain (zero log lines for those during the loop).WifiDevice.connectedChangedfires repeatedly (state oscillates).scannerEnabledgoing false does not break the loop.So the loop is NM-driven, but somehow only when QS.Networking is observing the device. We cannot reach the relevant NM property/method from the public QML API.
Hypothesis
Something in
Quickshell.Networking's internal D-Bus observation either:Device.Reapply/ re-activates the profile after the disconnect D-Bus call, orWifiDevice.disconnect()to a D-Bus method that lacks the "explicit-disconnect" semantics thatnmcli device disconnectcarries (org.freedesktop.NetworkManager.Device.Disconnectshould — but maybe the bus call is missing a flag or being followed by an activation).We cannot tell from QML which is which.
What would unblock us
Any of:
WifiDevicethat maps to NM'sDevice.Disconnectwith the explicit-disconnect bit set and is not followed by anything else.Quickshell.Networking.disconnect()implementation does carry explicit-disconnect, plus a way to diagnose what's actually re-arming autoconnect.Until then we keep
nmcli device disconnect <iface>as a single subprocess shell-out inservices/WifiScanner.qmland use native bindings for everything else (device enumeration, scan list, connect, forget, ethernet, wifiEnabled).Files in this repo
services/WifiScanner.qml— reverted to legacy nmcli implementation.services/Network.qml— fully native.modules/widgets/network/EthernetRow.qml— native.modules/controlcenter/CCTogglesSection.qml— native.