Fix Memory Leak(maybe??)

This commit is contained in:
tr1x_em committed 2025-08-07 11:21:36 +05:30
1 parent 8e4ef78cd7
commit 84af1ab3bc
5 files changed
+396 -138

No files matched your search

+3
View File
@@ -1,3 +1,6 @@
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk, GLib, Gtk
from fabric.bluetooth import BluetoothClient, BluetoothDevice
+216 -49
View File
@@ -1,18 +1,20 @@
from fabric.widgets.scale import Scale
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.box import Box
from services.mpris import MprisPlayer, MprisPlayerManager
# Standard library imports
import os
import re
import tempfile
import urllib.parse
import urllib.request
from typing import List
import threading
import weakref
from typing import List, Optional, Dict, Set
import gc
# Fabric imports
from fabric.widgets.scale import Scale
from widgets.wayland import WaylandWindow as Window
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.box import Box
from fabric.utils import bulk_connect, invoke_repeater, cooldown
from fabric.utils.helpers import get_relative_path
from fabric.widgets.image import Image
@@ -22,12 +24,17 @@ from fabric.widgets.svg import Svg
from gi.repository import GLib, GObject
from loguru import logger
# Local imports
from services.mpris import MprisPlayer, MprisPlayerManager
import config.data as data
CACHE_DIR = f"{data.CACHE_DIR}/media"
# Shared MPRIS manager to reduce memory usage
# Global memory management
_shared_mpris_manager = None
_widget_cache = weakref.WeakValueDictionary()
_artwork_cache = {}
_max_artwork_cache_size = 10 # Limit artwork cache to prevent memory bloat
def get_shared_mpris_manager():
@@ -38,16 +45,81 @@ def get_shared_mpris_manager():
return _shared_mpris_manager
def cleanup_artwork_cache():
"""Clean up artwork cache to prevent memory leaks."""
global _artwork_cache
if len(_artwork_cache) > _max_artwork_cache_size:
# Keep only the most recent items
items = list(_artwork_cache.items())
_artwork_cache = dict(items[-_max_artwork_cache_size:])
# Force garbage collection
gc.collect()
def cleanup_old_cache_files():
"""Clean up old artwork cache files (older than 6 hours for more aggressive cleanup)."""
"""Clean up old artwork cache files aggressively to save memory."""
try:
if not os.path.exists(CACHE_DIR):
return
import time
current_time = time.time()
six_hours_ago = current_time - (6 * 60 * 60) # 6 hours instead of 24
# Clean files older than 2 hours (more aggressive)
two_hours_ago = current_time - (2 * 60 * 60)
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
try:
if os.path.isfile(filepath):
file_mod_time = os.path.getmtime(filepath)
if file_mod_time < two_hours_ago:
os.remove(filepath)
logger.debug(f"Cleaned up old cache file: {filename}")
except Exception as e:
logger.warning(f"Failed to clean up cache file {filename}: {e}")
except Exception as e:
logger.error(f"Error during cache cleanup: {e}")
def get_artwork_cached(url: str) -> Optional[str]:
"""Get artwork with memory-efficient caching."""
if not url:
return None
# Check memory cache first
if url in _artwork_cache:
return _artwork_cache[url]
# Clean cache if too large
cleanup_artwork_cache()
try:
# Create cache directory if it doesn't exist
os.makedirs(CACHE_DIR, exist_ok=True)
# Generate cache filename
safe_filename = re.sub(r'[^\w\-_.]', '_', urllib.parse.urlparse(url).path.split('/')[-1])
if not safe_filename:
safe_filename = f"artwork_{hash(url)}"
cache_file = os.path.join(CACHE_DIR, safe_filename)
# Check if cached file exists and is recent (within 2 hours)
if os.path.exists(cache_file):
import time
if time.time() - os.path.getmtime(cache_file) < 7200: # 2 hours
_artwork_cache[url] = cache_file
return cache_file
# Download and cache
urllib.request.urlretrieve(url, cache_file)
_artwork_cache[url] = cache_file
return cache_file
except Exception as e:
logger.warning(f"Failed to cache artwork from {url}: {e}")
return None
for filename in os.listdir(CACHE_DIR):
filepath = os.path.join(CACHE_DIR, filename)
@@ -87,9 +159,11 @@ class EmbeddedExpandedPlayer(Box):
self.player_content = PlayerBoxStack(self.mpris_manager)
# Add escape key binding for navigation back
self._keybinding_added = False
try:
if hasattr(self.control_center, "add_keybinding"):
self.control_center.add_keybinding("Escape", self._on_back_clicked)
self._keybinding_added = True
except Exception:
pass # Ignore if keybinding fails
@@ -122,34 +196,78 @@ class EmbeddedExpandedPlayer(Box):
pass
def destroy(self):
"""Clean up resources"""
if hasattr(self.player_content, "destroy"):
self.player_content.destroy()
super().destroy()
"""Clean up resources and prevent memory leaks"""
logger.debug("🗑️ EmbeddedExpandedPlayer cleanup starting")
try:
# Destroy player content (PlayerBoxStack)
if hasattr(self, 'player_content') and hasattr(self.player_content, "destroy"):
self.player_content.destroy()
# Clean up back button
if hasattr(self, 'back_button') and hasattr(self.back_button, "destroy"):
self.back_button.destroy()
# Clean up any other widgets we might have
for child in list(self.get_children()):
try:
child.destroy()
except Exception as e:
logger.warning(f"Failed to destroy child widget: {e}")
# Clean up keybinding if it was added
if hasattr(self, '_keybinding_added') and self._keybinding_added:
try:
if hasattr(self.control_center, "remove_keybinding"):
self.control_center.remove_keybinding("Escape")
except Exception as e:
logger.warning(f"Failed to remove keybinding: {e}")
# Aggressively clean global caches
global _widget_cache, _artwork_cache
_widget_cache.clear()
_artwork_cache.clear()
logger.debug("🗑️ Cleared global widget and artwork caches")
# Clear references
if hasattr(self, 'control_center'):
self.control_center = None
if hasattr(self, 'mpris_manager'):
self.mpris_manager = None
# Force garbage collection
gc.collect()
logger.debug("🗑️ EmbeddedExpandedPlayer cleanup completed")
except Exception as e:
logger.error(f"Error during EmbeddedExpandedPlayer cleanup: {e}")
finally:
super().destroy()
class PlayerBoxStack(Box):
"""A widget that displays the current player information."""
"""Memory-optimized widget that displays current player information."""
def __init__(self, mpris_manager: MprisPlayerManager, **kwargs):
# Clean up old cache files on startup
cleanup_old_cache_files()
# The player stack
# The player stack with memory-efficient settings
self.player_stack = Stack(
# transition_type="slide-left-right",
# transition_duration=500,
name="player-stack",
# Disable transitions to reduce memory usage
transition_type="none",
)
self.current_stack_pos = 0
# List to store player buttons
# Store player buttons - cleaned up via explicit removal
self.player_buttons: list[Button] = []
self._player_widgets: Dict[str, Box] = weakref.WeakValueDictionary()
# Track signal connections for cleanup
# Track signal connections for cleanup using weak references
self._signal_connections = []
# Create a "No media playing" placeholder
# Create a lightweight "No media playing" placeholder
self.no_media_box = self._create_no_media_box()
super().__init__(orientation="v", name="media", children=[self.player_stack])
@@ -160,7 +278,7 @@ class PlayerBoxStack(Box):
self.mpris_manager = mpris_manager
# Track connections for cleanup - store (object, handler_id) tuples
# Track connections for cleanup
connections = bulk_connect(
self.mpris_manager,
{
@@ -168,43 +286,92 @@ class PlayerBoxStack(Box):
"player-vanished": self.on_lost_player,
},
)
# Store as (object, handler_id) tuples
for handler_id in connections:
self._signal_connections.append((self.mpris_manager, handler_id))
# Process existing players
for player in self.mpris_manager.players: # type: ignore
logger.info(
f"[PLAYER MANAGER] player found: {player.get_property('player-name')}",
)
logger.info(f"[PLAYER MANAGER] player found: {player.get_property('player-name')}")
self.on_new_player(self.mpris_manager, player)
# Schedule periodic memory cleanup and store source ID
self._cleanup_source_id = GLib.timeout_add_seconds(300, self._periodic_cleanup) # Every 5 minutes
def _periodic_cleanup(self):
"""Periodic cleanup to prevent memory leaks."""
try:
# Clean artwork cache
cleanup_artwork_cache()
# Remove destroyed widgets from tracking
destroyed_keys = []
for key, widget in self._player_widgets.items():
if not widget or not widget.get_parent():
destroyed_keys.append(key)
for key in destroyed_keys:
if key in self._player_widgets:
del self._player_widgets[key]
# Force garbage collection
gc.collect()
except Exception as e:
logger.warning(f"Error during periodic cleanup: {e}")
return True # Continue timer
def destroy(self):
"""Clean up resources when the widget is destroyed."""
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up player buttons
for button in self.player_buttons:
try:
button.destroy()
except Exception:
pass
self.player_buttons.clear()
# Clean up player boxes
for child in self.player_stack.get_children():
if hasattr(child, "destroy") and child != self.no_media_box:
try:
# Cancel any pending cleanup timer
if hasattr(self, '_cleanup_source_id') and self._cleanup_source_id:
try:
child.destroy()
GLib.source_remove(self._cleanup_source_id)
except Exception:
pass # Timer may have already been removed
# Disconnect all signal connections
for obj, handler_id in self._signal_connections:
try:
if obj and hasattr(obj, 'disconnect'):
obj.disconnect(handler_id)
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
# Clean up player widgets
for widget in list(self._player_widgets.values()):
try:
if widget and hasattr(widget, "destroy"):
widget.destroy()
except Exception:
pass
self._player_widgets.clear()
# Clean up player buttons explicitly
for button in list(self.player_buttons):
try:
if button and hasattr(button, "destroy"):
button.destroy()
except Exception:
pass
super().destroy()
# Clean up stack children
for child in self.player_stack.get_children():
if hasattr(child, "destroy") and child != self.no_media_box:
try:
child.destroy()
except Exception:
pass
# Force final garbage collection
gc.collect()
except Exception as e:
logger.error(f"Error during PlayerBoxStack cleanup: {e}")
finally:
super().destroy()
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
+158 -89
View File
@@ -1,40 +1,66 @@
from gi.repository import Gdk, GLib
# Standard library imports
import contextlib
import gc
import weakref
from pathlib import Path
from typing import Optional, Dict, Any
# Fabric imports
from fabric.core.service import Service, Signal, Property
from fabric.widgets.window import Window
from fabric.widgets.overlay import Overlay
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.button import Button
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.revealer import Revealer
from fabric.utils import idle_add, get_relative_path, invoke_repeater
from fabric.widgets.image import Image
# Gi imports
import gi
gi.require_version("Gtk", "3.0")
gi.require_version("GLib", "2.0")
from gi.repository import Gtk, GLib, Gdk
# Local imports
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from modules.controlcenter.player import PlayerBoxStack
from modules.controlcenter.per_app_volume import PerAppVolumeControl
from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer
from services.modus import modus_service
from widgets.wayland import WaylandWindow as Window
from loguru import logger
# Memory monitoring
from debug_memory import set_memory_baseline, log_memory
from fabric.utils import idle_add
from fabric.utils.helpers import (
get_relative_path,
)
from fabric.utils.helpers import get_relative_path
from fabric.widgets.box import Box
from fabric.widgets.button import Button
from fabric.widgets.centerbox import CenterBox
from fabric.widgets.label import Label
from fabric.widgets.scale import Scale
from fabric.widgets.svg import Svg
from widgets.wayland import WaylandWindow as Window
# Local imports
from modules.controlcenter.bluetooth import BluetoothConnections
from modules.controlcenter.wifi import WifiConnections
from services.brightness import Brightness
from utils.roam import audio_service, modus_service
from widgets.wayland import WaylandWindow as Window
from modules.controlcenter.player import PlayerBoxStack
from modules.controlcenter.per_app_volume import PerAppVolumeControl
from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer
from modules.controlcenter.expanded_player import EmbeddedExpandedPlayer, get_shared_mpris_manager
from services.mpris import MprisPlayerManager
from fabric.widgets.svg import Svg
from fabric.utils.helpers import get_relative_path
brightness_service = Brightness.get_initial()
# Shared MPRIS manager to reduce memory usage
_shared_mpris_manager = None
def get_shared_mpris_manager():
"""Get shared MPRIS manager instance to reduce memory usage."""
global _shared_mpris_manager
if _shared_mpris_manager is None:
_shared_mpris_manager = MprisPlayerManager()
return _shared_mpris_manager
# Memory management globals
_widget_cache = weakref.WeakValueDictionary()
_cleanup_timer_id = None
class ModusControlCenter(Window):
@@ -348,65 +374,41 @@ class ModusControlCenter(Window):
self.children = self.center_box
# Set memory baseline for monitoring
set_memory_baseline("Control Center Initialized")
# Connect to visibility changes for cleanup
self.connect("notify::visible", self._on_visibility_changed)
# Periodic cleanup timer (every 5 minutes when visible)
GLib.timeout_add_seconds(300, self._periodic_cleanup)
def destroy(self):
"""Proper cleanup when control center is destroyed"""
# Disconnect all signal connections
for connection in self._signal_connections:
try:
if hasattr(connection, "disconnect"):
connection.disconnect()
elif hasattr(connection, "handler_disconnect"):
# For GObject connections
connection.handler_disconnect()
except Exception:
pass
self._signal_connections.clear()
# Clean up all widgets
self._cleanup_when_hidden()
# Clean up managers
if hasattr(self, "wifi_man") and self.wifi_man:
try:
if hasattr(self.wifi_man, "destroy"):
self.wifi_man.destroy()
except Exception:
pass
if hasattr(self, "bluetooth_man") and self.bluetooth_man:
try:
if hasattr(self.bluetooth_man, "destroy"):
self.bluetooth_man.destroy()
except Exception:
pass
super().destroy()
# Start periodic memory cleanup (every 5 minutes)
global _cleanup_timer_id
_cleanup_timer_id = GLib.timeout_add_seconds(300, self._periodic_cleanup)
def _on_visibility_changed(self, widget, param):
"""Handle visibility changes for memory management"""
if not self.get_visible():
logger.debug("Control center hidden - starting cleanup")
self._cleanup_when_hidden()
def _cleanup_when_hidden(self):
"""Clean up resources when widget is hidden to reduce memory usage"""
# Clean up music widget content if it exists
if self._music_widget_content and hasattr(
self._music_widget_content, "destroy"
):
log_memory("🧹 BEFORE cleanup when hidden")
logger.debug("Starting hidden cleanup - freeing widget memory")
# Clean up music widget content properly
if self._music_widget_content:
try:
# Remove from parent container first
current_children = list(self.music_widget.children)
if self._music_widget_content in current_children:
current_children.remove(self._music_widget_content)
self.music_widget.children = current_children
# Destroy the content to free memory
self._music_widget_content.destroy()
# Destroy the content to free memory completely
if hasattr(self._music_widget_content, "destroy"):
self._music_widget_content.destroy()
logger.debug("Destroyed music widget content to free memory")
self._music_widget_content = None
self._music_initialized = False
except Exception as e:
@@ -423,33 +425,43 @@ class ModusControlCenter(Window):
except Exception:
pass
# Clean up expanded player widget
# Clean up expanded player widget - BUT preserve for reuse
if self._expanded_player_widget and hasattr(
self._expanded_player_widget, "destroy"
):
try:
self._expanded_player_widget.destroy()
self._expanded_player_widget = None
self._expanded_player_initialized = False
except Exception:
pass
# DON'T destroy the widget, just clean its internal state
if hasattr(self._expanded_player_widget, 'player_content'):
# Clean up only the player content, keep the widget shell
if hasattr(self._expanded_player_widget.player_content, '_periodic_cleanup'):
self._expanded_player_widget.player_content._periodic_cleanup()
logger.debug("Cleaned expanded player internal state (preserved for reuse)")
# DON'T set to None - keep for reuse!
# self._expanded_player_widget = None
# self.expanded_player_widgets = None
# self.expanded_player_center_box = None
except Exception as e:
logger.warning(f"Failed to clean expanded player state: {e}")
# Reset widget containers to free memory
# Reset widget containers to free memory (except expanded player for reuse)
self.bluetooth_widgets = None
self.wifi_widgets = None
self.per_app_volume_widgets = None
self.expanded_player_widgets = None
# Keep expanded_player_widgets for reuse!
# self.expanded_player_widgets = None
# Reset center boxes
# Reset center boxes (except expanded player for reuse)
self.bluetooth_center_box = None
self.wifi_center_box = None
self.per_app_volume_center_box = None
self.expanded_player_center_box = None
# Keep expanded_player_center_box for reuse!
# self.expanded_player_center_box = None
# Force garbage collection
import gc
gc.collect()
log_memory("🧹 AFTER cleanup when hidden")
def _periodic_cleanup(self):
"""Periodic cleanup to keep memory usage low"""
@@ -467,14 +479,22 @@ class ModusControlCenter(Window):
def _ensure_music_widget(self):
"""Lazy load music widget content"""
if not self._music_initialized:
log_memory("🎼 BEFORE creating music widget content")
logger.debug("Creating new PlayerBoxStack instance")
self._music_widget_content = PlayerBoxStack(
get_shared_mpris_manager(), control_center=self
)
log_memory("🎼 AFTER creating PlayerBoxStack")
# Add to the music widget's children list
current_children = list(self.music_widget.children)
current_children.append(self._music_widget_content)
self.music_widget.children = current_children
self._music_initialized = True
log_memory("🎼 AFTER adding to music widget container")
else:
logger.debug("Music widget already initialized")
log_memory("♻️ REUSING existing music widget")
def _ensure_bluetooth_widgets(self):
"""Lazy load bluetooth widgets"""
@@ -556,10 +576,16 @@ class ModusControlCenter(Window):
self.per_app_volume_center_box.set_size_request(300, -1)
def _ensure_expanded_player_widgets(self):
"""Lazy load expanded player widgets"""
"""Lazy load expanded player widgets with reuse optimization"""
if self.expanded_player_widgets is None:
log_memory("🔧 BEFORE creating expanded player widgets")
logger.debug("Creating new expanded player widgets")
if self._expanded_player_widget is None:
logger.debug("Creating new EmbeddedExpandedPlayer instance")
self._expanded_player_widget = EmbeddedExpandedPlayer(self)
log_memory("🔧 AFTER creating EmbeddedExpandedPlayer")
else:
logger.debug("Reusing existing EmbeddedExpandedPlayer instance")
self.expanded_player_widgets = Box(
orientation="vertical",
@@ -573,6 +599,10 @@ class ModusControlCenter(Window):
start_children=[self.expanded_player_widgets]
)
self.expanded_player_center_box.set_size_request(300, -1)
log_memory("🔧 AFTER creating expanded player containers")
else:
logger.debug("Reusing existing expanded player widgets")
log_memory("♻️ REUSING existing expanded player widgets")
def set_dont_disturb(self, *_):
self.focus_mode = not self.focus_mode
@@ -669,16 +699,20 @@ class ModusControlCenter(Window):
self.has_per_app_volume_open = False
def open_expanded_player(self, *_):
log_memory("🎵 BEFORE opening expanded player")
self._ensure_expanded_player_widgets()
idle_add(lambda *_: self.set_children(self.expanded_player_center_box))
self.has_expanded_player_open = True
# Refresh the player when opening
if self._expanded_player_widget:
self._expanded_player_widget.refresh()
log_memory("🎵 AFTER opening expanded player")
def close_expanded_player(self, *_):
log_memory("🔒 BEFORE closing expanded player")
idle_add(lambda *_: self.set_children(self.center_box))
self.has_expanded_player_open = False
log_memory("🔒 AFTER closing expanded player")
def _set_mousecapture(self, visible: bool):
if visible:
@@ -776,24 +810,59 @@ class ModusControlCenter(Window):
self.set_visible(False)
def destroy(self):
"""Clean up resources when widget is destroyed"""
# Disconnect all signal connections
for connection in self._signal_connections:
try:
connection.disconnect()
except:
pass
"""Enhanced cleanup of resources when widget is destroyed."""
try:
# Cancel any periodic cleanup timers
global _cleanup_timer_id
if _cleanup_timer_id:
GLib.source_remove(_cleanup_timer_id)
_cleanup_timer_id = None
# Clean up heavy components
if self._wifi_man:
self._wifi_man.destroy()
if self._bluetooth_man:
self._bluetooth_man.destroy()
if self._music_widget_content:
self._music_widget_content.destroy()
if self._per_app_volume_widget:
self._per_app_volume_widget.destroy()
if self._expanded_player_widget:
self._expanded_player_widget.destroy()
# Disconnect all signal connections with better error handling
for connection in self._signal_connections:
try:
if connection and hasattr(connection, 'disconnect'):
connection.disconnect()
except Exception as e:
logger.warning(f"Failed to disconnect signal: {e}")
self._signal_connections.clear()
super().destroy()
# Clean up heavy components with memory optimization
components_to_cleanup = [
('wifi_man', getattr(self, 'wifi_man', None)),
('bluetooth_man', getattr(self, 'bluetooth_man', None)),
('_music_widget_content', self._music_widget_content),
('_per_app_volume_widget', self._per_app_volume_widget),
('_expanded_player_widget', self._expanded_player_widget)
]
for attr_name, component in components_to_cleanup:
if component and hasattr(component, 'destroy'):
try:
component.destroy()
setattr(self, attr_name, None)
except Exception as e:
logger.warning(f"Failed to destroy {attr_name}: {e}")
# Clean up widget containers and references
widget_containers = [
'bluetooth_widgets', 'wifi_widgets', 'per_app_volume_widgets',
'expanded_player_widgets', 'bluetooth_center_box', 'wifi_center_box',
'per_app_volume_center_box', 'expanded_player_center_box'
]
for container in widget_containers:
if hasattr(self, container):
setattr(self, container, None)
# Clear any cached widgets
global _widget_cache
_widget_cache.clear()
# Force garbage collection for immediate memory cleanup
gc.collect()
except Exception as e:
logger.error(f"Error during control center destruction: {e}")
finally:
super().destroy()
+16
View File
@@ -132,6 +132,22 @@ class PlayerBoxStack(Box):
super().destroy()
def _periodic_cleanup(self):
"""Light cleanup for reuse - clean internal state but preserve widget"""
try:
# Reset to no media state
self.player_stack.children = [self.no_media_box]
# Clear player buttons but don't destroy them
self.player_buttons.clear()
# Reset stack position
self.current_stack_pos = 0
logger.debug("PlayerBoxStack periodic cleanup completed")
except Exception as e:
logger.warning(f"PlayerBoxStack periodic cleanup failed: {e}")
def _create_no_media_box(self):
"""Create a placeholder box for when no media is playing."""
fallback_cover_path = f"{data.HOME_DIR}/.current.wall"
+3
View File
@@ -1,3 +1,6 @@
import gi
gi.require_version('Gtk', '3.0')
gi.require_version('Gdk', '3.0')
from gi.repository import Gdk, GLib, Gtk
from fabric.widgets.box import Box