From c818adbb5a48aa96de583b1ad8e620ae868fb37c Mon Sep 17 00:00:00 2001 From: Sergey Arkhipov Date: Tue, 1 Sep 2026 21:13:11 +0300 Subject: [PATCH 1/3] Wait for device activation after connection switch Ensures NetworkManager has time to tear down secondary connections (VPN, etc.) before the script exits. --- networkmanager_dmenu | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/networkmanager_dmenu b/networkmanager_dmenu index e092396..a61cbda 100755 --- a/networkmanager_dmenu +++ b/networkmanager_dmenu @@ -810,6 +810,7 @@ def process_ap(nm_ap, is_active, adapter): nm_ap, ) LOOP.run() + wait_for_device_activated(adapter) else: sec = ap_security(nm_ap) if "802.1X" in sec: @@ -1706,6 +1707,25 @@ def run(): # pylint: disable=too-many-locals sel = get_selection(actions) sel() +def wait_for_device_activated(device, timeout=30): + """Wait until the device actually reaches ACTIVATED state. + + This mirrors the blocking behavior of `nmcli connection up`. + It gives NetworkManager time to properly tear down any previous + secondary connections (VPN, WireGuard, etc.) before the script exits. + """ + if device.get_state() == NM.DeviceState.ACTIVATED: + return + + def on_state_changed(dev, new_state, old_state, reason): + if new_state == NM.DeviceState.ACTIVATED: + LOOP.quit() + + handler_id = device.connect("state-changed", on_state_changed) + timeout_id = GLib.timeout_add_seconds(timeout, LOOP.quit) + LOOP.run() + GLib.source_remove(timeout_id) + device.disconnect(handler_id) def main(): """Main. Enables script to be re-run after a WiFi rescan""" From 40d5cb0d06479046733a96b8f243670020309080 Mon Sep 17 00:00:00 2001 From: Scott Hansen Date: Wed, 9 Sep 2026 19:50:35 -0700 Subject: [PATCH 2/3] Fix the crash and silent timeout in the activation wait --- networkmanager_dmenu | 64 +++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/networkmanager_dmenu b/networkmanager_dmenu index a61cbda..67650fd 100755 --- a/networkmanager_dmenu +++ b/networkmanager_dmenu @@ -849,6 +849,51 @@ def activate_cb(dev, res, data): LOOP.quit() +def wait_for_device_activated(device, timeout=30): + """Block until the device has finished activating + + NetworkManager ties the activation of a connection's secondaries (the VPN + profiles listed in `connection.secondaries`) to the D-Bus client that + asked for it, so returning as soon as the activation request is accepted + leaves the VPN to be torn down again the moment nmdm exits. Stay on the + bus until the device reports ACTIVATED, the state it only reaches once + its secondaries are up. + + Args: device - NM.Device + timeout - seconds to wait before giving up + + """ + if device.get_state() == NM.DeviceState.ACTIVATED: + return + + timed_out = False + + def on_state_changed(dev, new_state, old_state, reason): + if new_state == NM.DeviceState.ACTIVATED: + LOOP.quit() + + def on_timeout(): + nonlocal timed_out + timed_out = True + LOOP.quit() + return GLib.SOURCE_REMOVE + + handler_id = device.connect("state-changed", on_state_changed) + timeout_id = GLib.timeout_add_seconds(timeout, on_timeout) + LOOP.run() + if not timed_out: + GLib.source_remove(timeout_id) + # NM.Device shadows GObject's `disconnect`, and its version disconnects + # the device from the network instead of the signal handler + device.handler_disconnect(handler_id) + if timed_out: + notify( + f"{device.get_iface()} did not finish activating", + f"Gave up waiting after {timeout} seconds", + urgency="critical", + ) + + def deactivate_cb(dev, res, data): """Notification if deactivate connection completed successfully""" try: @@ -1707,25 +1752,6 @@ def run(): # pylint: disable=too-many-locals sel = get_selection(actions) sel() -def wait_for_device_activated(device, timeout=30): - """Wait until the device actually reaches ACTIVATED state. - - This mirrors the blocking behavior of `nmcli connection up`. - It gives NetworkManager time to properly tear down any previous - secondary connections (VPN, WireGuard, etc.) before the script exits. - """ - if device.get_state() == NM.DeviceState.ACTIVATED: - return - - def on_state_changed(dev, new_state, old_state, reason): - if new_state == NM.DeviceState.ACTIVATED: - LOOP.quit() - - handler_id = device.connect("state-changed", on_state_changed) - timeout_id = GLib.timeout_add_seconds(timeout, LOOP.quit) - LOOP.run() - GLib.source_remove(timeout_id) - device.disconnect(handler_id) def main(): """Main. Enables script to be re-run after a WiFi rescan""" From f0cbd93ee0b8f7b2cf986a87e0532dc140803982 Mon Sep 17 00:00:00 2001 From: Scott Hansen Date: Thu, 10 Sep 2026 08:16:42 -0700 Subject: [PATCH 3/3] Wait for activation on the saved and new connection paths too --- networkmanager_dmenu | 40 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/networkmanager_dmenu b/networkmanager_dmenu index 67650fd..be7fd59 100755 --- a/networkmanager_dmenu +++ b/networkmanager_dmenu @@ -810,7 +810,7 @@ def process_ap(nm_ap, is_active, adapter): nm_ap, ) LOOP.run() - wait_for_device_activated(adapter) + wait_for_activation(adapter) else: sec = ap_security(nm_ap) if "802.1X" in sec: @@ -832,9 +832,11 @@ def activate_cb(dev, res, data): Finishing here only means NM took the request, the activation itself settles long after nmdm has exited, so do not claim it succeeded. """ + global ACTIVE_CONN try: conn = dev.activate_connection_finish(res) if conn is not None: + ACTIVE_CONN = conn notify(f"Connecting to {conn.get_id()}") else: notify( @@ -894,6 +896,28 @@ def wait_for_device_activated(device, timeout=30): ) +def wait_for_activation(device=None): + """Block until the activation just requested has finished + + `process_ap` and `set_new_connection` activate against an adapter and + pass it in. `process_vpngsm` has no adapter to pass, so take the device + from the connection NetworkManager just activated instead. + + Args: device - NM.Device to wait on, or None to use the device of the + connection that was activated + + """ + if ACTIVE_CONN is None: + # The request itself failed, there is nothing left to wait for + return + if device is None: + devices = ACTIVE_CONN.get_devices() + if not devices: + return + device = devices[0] + wait_for_device_activated(device) + + def deactivate_cb(dev, res, data): """Notification if deactivate connection completed successfully""" try: @@ -918,8 +942,12 @@ def process_vpngsm(con, activate): CLIENT.activate_connection_async( con, None, None, None, activate_cb, con ) - else: - CLIENT.deactivate_connection_async(con, None, deactivate_cb, con) + LOOP.run() + # Saved connections are activated without an adapter, so the device + # to wait on is whichever one NetworkManager picked + wait_for_activation() + return + CLIENT.deactivate_connection_async(con, None, deactivate_cb, con) LOOP.run() @@ -1478,6 +1506,7 @@ def set_new_connection(nm_ap, nm_pw, adapter): profile, adapter, nm_ap.get_path(), None, verify_conn, profile ) LOOP.run() + wait_for_activation(adapter) def create_wifi_profile(nm_ap, password, adapter): @@ -1545,6 +1574,7 @@ def verify_conn(client, result, data): is an error. """ + global ACTIVE_CONN conn = None try: act_conn = client.add_and_activate_connection_finish(result) @@ -1570,6 +1600,7 @@ def verify_conn(client, result, data): conn.delete_async(None, cleanup_cb, None) return else: + ACTIVE_CONN = act_conn notify(f"Connecting to {conn.get_id()}") LOOP.quit() @@ -1755,7 +1786,8 @@ def run(): # pylint: disable=too-many-locals def main(): """Main. Enables script to be re-run after a WiFi rescan""" - global CLIENT, CONNS, LOOP + global CLIENT, CONNS, LOOP, ACTIVE_CONN + ACTIVE_CONN = None CLIENT = NM.Client.new(None) LOOP = GLib.MainLoop() CONNS = CLIENT.get_connections()