fix: add destroy() and signal cleanup across widgets, fix settings window + menu regressions

P0 leak fixes: context/action menus destroyed on deactivate, config
callbacks unregistered in settings widgets, recorder and server signal
IDs stored and disconnected, bluetooth client closed and unsubscribed,
rfkill timer removed, seek timers cancelled, dropdown and options menus
freed, app name cache LRU-bounded.

Fix desktop 'Edit Widgets' and settings menu regressions by deferring
destroy out of Gtk signal handlers via GLib.idle_add.

Fix settings entry fields by connecting the activate signal explicitly
(Enter saves the value).
This commit is contained in:
sankalp committed 2026-08-01 19:44:39 +05:45
1 parent 55fc3fba21
commit d8696b9198
13 files changed
+180 -44

No files matched your search

+34 -20
View File
@@ -677,32 +677,46 @@ class BluetoothClient(Service):
None,
)
self._bus.signal_subscribe(
BLUEZ_SERVICE,
DBUS_OM_IFACE,
"InterfacesAdded",
None,
None,
Gio.DBusSignalFlags.NONE,
self._on_interfaces_added,
None,
)
self._bus.signal_subscribe(
BLUEZ_SERVICE,
DBUS_OM_IFACE,
"InterfacesRemoved",
None,
None,
Gio.DBusSignalFlags.NONE,
self._on_interfaces_removed,
None,
)
self._bus_sub_ids = [
self._bus.signal_subscribe(
BLUEZ_SERVICE,
DBUS_OM_IFACE,
"InterfacesAdded",
None,
None,
Gio.DBusSignalFlags.NONE,
self._on_interfaces_added,
None,
),
self._bus.signal_subscribe(
BLUEZ_SERVICE,
DBUS_OM_IFACE,
"InterfacesRemoved",
None,
None,
Gio.DBusSignalFlags.NONE,
self._on_interfaces_removed,
None,
),
]
self._cached_rfkill_blocked = _rfkill_soft_blocked()
self._rfkill_timeout_id = GLib.timeout_add_seconds(10, self._check_rfkill)
self._populate_from_object_manager()
def close(self):
"""Release the rfkill timer and unsubscribe from the DBus bus."""
if self._rfkill_timeout_id:
GLib.source_remove(self._rfkill_timeout_id)
self._rfkill_timeout_id = 0
for sub_id in self._bus_sub_ids:
self._bus.signal_unsubscribe(sub_id)
self._bus_sub_ids.clear()
for adapter in list(self._adapters.values()):
adapter.close()
self._adapters.clear()
def _check_rfkill(self) -> bool:
blocked = _rfkill_soft_blocked()
if blocked != self._cached_rfkill_blocked:
+9
View File
@@ -241,6 +241,15 @@ class GameModeButton(Box):
else:
self.game_icon.remove_style_class("connected")
def destroy(self):
try:
self.gamemode_service.disconnect_by_func(self.update_state)
except Exception as e:
logger.warning(
f"[battery_widget] disconnect gamemode update_state failed: {e}"
)
super().destroy()
class BatteryControl(Box):
def __init__(self, parent, **kwargs):
+4
View File
@@ -106,6 +106,10 @@ class ModusDropdown:
def popup(self, widget, source_button=None):
self.popup_at_widget(widget, source_button)
def destroy(self):
"""Destroy the Gtk.Menu and its children."""
self._menu.destroy()
def dropdown_option(label, accel=None, command=None, callback=None, sensitive=True):
"""Create a dropdown menu option dict.
+20 -12
View File
@@ -1,14 +1,18 @@
import os
from collections import OrderedDict
class AppName:
_MAX_CACHE = 128
def __init__(self, path="/usr/share/applications"):
self.files = os.listdir(path)
self.path = path
self._cache: dict[str, str | None] = {}
self._cache: OrderedDict[str, str | None] = OrderedDict()
def get_app_name(self, wmclass, _format_=False):
if wmclass in self._cache:
self._cache.move_to_end(wmclass)
return self._cache[wmclass]
desktop_file = ""
@@ -17,18 +21,22 @@ class AppName:
desktop_file = f
if desktop_file == "":
self._cache[wmclass] = None
return None
result = None
else:
desktop_app_name = wmclass
with open(os.path.join(self.path, desktop_file)) as f:
lines = f.readlines()
for line in lines:
if line.startswith("Name="):
desktop_app_name = line.split("=")[1].strip()
break
result = desktop_app_name
desktop_app_name = wmclass
with open(os.path.join(self.path, desktop_file)) as f:
lines = f.readlines()
for line in lines:
if line.startswith("Name="):
desktop_app_name = line.split("=")[1].strip()
break
self._cache[wmclass] = desktop_app_name
return desktop_app_name
self._cache[wmclass] = result
self._cache.move_to_end(wmclass)
if len(self._cache) > self._MAX_CACHE:
self._cache.popitem(last=False)
return result
def format_app_name(self, title, wmclass, update=False):
# Handle case when both title and wmclass are empty (no active window)
+6
View File
@@ -284,6 +284,7 @@ class BluetoothDeviceSlot(Box):
menu.append(item_remove)
menu.show_all()
menu.connect("deactivate", lambda m: GLib.idle_add(m.destroy))
menu.popup_at_pointer(event)
return True
@@ -661,6 +662,11 @@ class BluetoothConnections(PullToRefreshMixin, Box):
f"[Bluetooth] Failed to disconnect client signal {sig_id}: {e}"
)
self._client_signal_ids.clear()
if self.client:
try:
self.client.close()
except Exception as e:
logger.error(f"[Bluetooth] Failed to close client: {e}")
def close_bluetooth(self):
"""Called when Bluetooth panel is being closed"""
@@ -460,6 +460,12 @@ class PlayerBox(Box):
def destroy(self):
self.exit = True
self.suspend()
if self._seek_timeout:
GLib.source_remove(self._seek_timeout)
self._seek_timeout = None
if self._debounce_seek:
GLib.source_remove(self._debounce_seek)
self._debounce_seek = None
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
+8 -1
View File
@@ -164,6 +164,7 @@ class DesktopWidgetWindow(WaylandWindow):
self._dragging_eb: Gtk.EventBox | None = None
self._drag_drop_success: bool = False
self._input_rebuild_pending: bool = False
self._context_menu: Gtk.Menu | None = None
self._root = Box(h_expand=True, v_expand=True)
self._root.add(self._fixed)
@@ -296,7 +297,7 @@ class DesktopWidgetWindow(WaylandWindow):
return False
def _show_context_menu(self, event: Gdk.EventButton) -> None:
menu = Gtk.Menu()
menu = self._context_menu = Gtk.Menu()
label_text = "Done Edit" if self._edit_mode else "Edit Widgets"
edit_item = Gtk.CheckMenuItem(label=label_text)
@@ -304,9 +305,15 @@ class DesktopWidgetWindow(WaylandWindow):
edit_item.connect("toggled", self._on_edit_toggled)
menu.append(edit_item)
menu.connect("deactivate", self._on_context_menu_deactivate)
menu.show_all()
menu.popup_at_pointer(event)
def _on_context_menu_deactivate(self, menu) -> None:
if self._context_menu is menu:
self._context_menu = None
GLib.idle_add(menu.destroy)
# edit mode
def _on_edit_toggled(self, item) -> None:
+21 -2
View File
@@ -16,7 +16,7 @@ from fabric.widgets.revealer import Revealer
from fabric.widgets.wayland import WaylandWindow as Window
import shared.data as data
from services.config import get_config, on_config_change
from services.config import get_config, off_config_change, on_config_change
from services.modus import notification_service
from shared.widgets.clipping_box import ClippingBox
from shared.widgets.close_button_revealer import CloseButtonRevealerMixin
@@ -1158,7 +1158,9 @@ class ModusNoti(Window):
self.TRANSITION_DELAY = 100 # Smoother transition timing
self.DEBOUNCE_DELAY = 50 # Prevent rapid fire notifications
self._server.connect("notification-added", self.on_new_notification)
self._notification_added_handler_id = self._server.connect(
"notification-added", self.on_new_notification
)
Window.__init__(
self,
anchor="top right",
@@ -1438,3 +1440,20 @@ class ModusNoti(Window):
self.update_config(changes)
except Exception as e:
logger.error(f"[ModusNoti] Error handling config change: {e}")
def destroy(self):
off_config_change(self._on_config_changed)
if self._transition_timer_id:
GLib.source_remove(self._transition_timer_id)
self._transition_timer_id = None
if self._debounce_timer_id:
GLib.source_remove(self._debounce_timer_id)
self._debounce_timer_id = None
if self._notification_added_handler_id:
try:
self._server.disconnect(self._notification_added_handler_id)
except Exception as e:
logger.error(f"[ModusNoti] Failed to disconnect server: {e}")
self._notification_added_handler_id = None
self.clear_notification_queue()
super().destroy()
@@ -662,6 +662,7 @@ class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget):
menu.append(close_item)
menu.show_all()
self._action_menu = menu
def show_menu(btn):
# Show the menu under the button
@@ -712,6 +713,9 @@ class NotificationCenterWidget(CloseButtonRevealerMixin, NotificationWidget):
def destroy(self):
self._cancel_close_button_hide()
if hasattr(self, "_action_menu") and self._action_menu is not None:
self._action_menu.destroy()
self._action_menu = None
super().destroy()
@@ -498,6 +498,10 @@ def add_destroy_to_indicators():
self.bluetooth.disconnect_by_func(self.on_bluetooth_direct_changed)
self.bluetooth.disconnect_by_func(self.on_device_added)
self.bluetooth.disconnect_by_func(self.on_device_removed)
try:
self.bluetooth.close()
except Exception as e:
logger.error(f"Error closing bluetooth indicator client: {e}")
except Exception as e:
logger.error(f"Error disconnecting bluetooth indicator signals: {e}")
try:
+20 -3
View File
@@ -101,11 +101,28 @@ class ScreenCaptureWindow(Window):
self._tool_group.select_id("screenshot-screen")
self._tool_group.on_change(self._on_tool_changed)
screen_recorder_service.connect("started", self._on_recording_started)
screen_recorder_service.connect("stopped", self._on_recording_stopped)
self.connect("key-press-event", self._on_key_press)
self._recorder_handler_ids = [
screen_recorder_service.connect("started", self._on_recording_started),
screen_recorder_service.connect("stopped", self._on_recording_stopped),
]
def destroy(self):
for hid in self._recorder_handler_ids:
try:
screen_recorder_service.disconnect(hid)
except Exception as e:
logger.warning(f"[main] disconnect recorder signal failed: {e}")
self._recorder_handler_ids = []
if self._delay_timeout_id is not None:
GLib.source_remove(self._delay_timeout_id)
self._delay_timeout_id = None
if self._options_menu is not None:
self._options_menu.destroy()
self._options_menu = None
super().destroy()
def do_focus(self, direction):
"""
Disable GTK's built-in Tab/Shift+Tab focus traversal so those keys
+4
View File
@@ -213,6 +213,10 @@ class OptionsMenu:
None,
)
def destroy(self):
"""Destroy the persistent Gtk.Menu and its children."""
self._menu.destroy()
def get_timer_delay(self) -> int:
return [0, 5, 10][self._timer_idx]
+40 -6
View File
@@ -1,4 +1,4 @@
from fabric.utils import Gdk, Gtk
from fabric.utils import Gdk, GLib, Gtk
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
@@ -7,7 +7,7 @@ from fabric.widgets.label import Label
from fabric.widgets.scrolledwindow import ScrolledWindow
from fabric.widgets.stack import Stack
from services.config import config, on_config_change
from services.config import config, off_config_change, on_config_change
from shared.widgets.smooth_switch import SmoothSwitch
from utils.gtk_utils import setup_cursor_hover, svg_file
@@ -66,6 +66,10 @@ class SettingsSwitch(SmoothSwitch):
config().set(self.config_key, state)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsEntry(Entry):
def __init__(self, config_key: str, **kwargs):
@@ -75,9 +79,9 @@ class SettingsEntry(Entry):
super().__init__(
name="settings-entry",
text=initial_value,
on_activate=self.on_change,
**kwargs,
)
self.connect("activate", self.on_change)
# Sync with external config changes
on_config_change(self._on_config_change)
@@ -101,6 +105,10 @@ class SettingsEntry(Entry):
config().set(self.config_key, value)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsComboBox(Gtk.ComboBoxText):
def __init__(self, config_key: str, options: list, **kwargs):
@@ -134,6 +142,10 @@ class SettingsComboBox(Gtk.ComboBoxText):
config().set(self.config_key, value)
config().save()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsList(Box):
"""A list of tags that can be added or removed"""
@@ -146,8 +158,8 @@ class SettingsList(Box):
self.entry = Entry(
name="settings-list-entry",
placeholder="Add new item...",
on_activate=self.on_add,
)
self.entry.connect("activate", self.on_add)
self.add(self.list_box)
self.add(self.entry)
@@ -206,6 +218,10 @@ class SettingsList(Box):
config().save()
self._update_list()
def destroy(self):
off_config_change(self._on_config_change)
super().destroy()
class SettingsPage(ScrolledWindow):
def __init__(self, title: str, rows: list, **kwargs):
@@ -246,8 +262,11 @@ class SettingsWindow(Gtk.Window):
self.set_position(Gtk.WindowPosition.CENTER)
self.set_visible(False)
# Hide instead of destroy so the __main__ reference stays valid
self.connect("delete-event", lambda *_: self.hide() or True)
# Destroy on close so the widget tree is freed; get_settings_window
# recreates it on next open. Destruction is deferred out of the
# delete-event emission to avoid tearing the window down mid-signal.
self.connect("delete-event", self._on_delete_event)
self.connect("destroy", self._on_window_destroyed)
self.stack = Stack(
name="settings-stack",
@@ -508,6 +527,11 @@ class SettingsWindow(Gtk.Window):
)
def toggle(self):
if getattr(self, "_destroyed", False):
# A stale reference (e.g. __main__.settings) outlived this window.
get_settings_window().toggle()
return
if not self.get_visible():
self.show_all()
self.present()
@@ -522,6 +546,16 @@ class SettingsWindow(Gtk.Window):
else:
self.hide()
def _on_delete_event(self, *_):
GLib.idle_add(self.destroy)
return True
def _on_window_destroyed(self, *_):
global _settings_window
self._destroyed = True
if _settings_window is self:
_settings_window = None
_settings_window = None