diff --git a/src/controller/mainController.py b/src/controller/mainController.py index 66358ffa..a22b2faa 100644 --- a/src/controller/mainController.py +++ b/src/controller/mainController.py @@ -18,6 +18,7 @@ from pubsub import pub from extra import SoundsTutorial from update import updater from wxUI import view, dialogs, commonMessageDialogs, sysTrayIcon +from wxUI.dialogs import diagnostics from keyboard_handler.wx_handler import WXKeyboardHandler from sessionmanager import manager, sessionManager from controller import buffers @@ -25,6 +26,7 @@ from mysc import restart from mysc import localization from mysc.thread_utils import call_threaded from mysc.repeating_timer import RepeatingTimer +from mysc.memory_utils import get_process_usage from controller.mastodon import handler as MastodonHandler from controller.blueski import handler as BlueskiHandler # Added import from . import settings, userAlias @@ -185,6 +187,9 @@ class Controller(object): widgetUtils.connect_event(self.view, widgetUtils.MENU, self.create_filter, self.view.filter) widgetUtils.connect_event(self.view, widgetUtils.MENU, self.manage_filters, self.view.manage_filters) + if hasattr(self.view, "diagnostics"): + widgetUtils.connect_event(self.view, widgetUtils.MENU, self.show_diagnostics, self.view.diagnostics) + def set_systray_icon(self): self.systrayIcon = sysTrayIcon.SysTrayIcon() widgetUtils.connect_event(self.systrayIcon, widgetUtils.MENU, self.post_tweet, menuitem=self.systrayIcon.post) @@ -233,6 +238,7 @@ class Controller(object): self.menubar_current_handler = "" # Handlers are special objects as they manage the mapping of available features and events in different social networks. self.handlers = dict() + self.diagnostics_dialog = None self.view.prepare() self.bind_other_events() self.set_systray_icon() @@ -620,6 +626,9 @@ class Controller(object): self.exit_() def exit_(self, *args, **kwargs): + if self.diagnostics_dialog is not None and not self.diagnostics_dialog.IsBeingDeleted(): + self.diagnostics_dialog.Destroy() + self.diagnostics_dialog = None for i in self.buffers: i.save_positions() log.debug("Exiting...") log.debug("Saving global configuration...") @@ -635,6 +644,36 @@ class Controller(object): os.remove(pidpath) widgetUtils.exit_application() + def get_diagnostics_snapshot(self): + """Return the source-only resource counters displayed by the diagnostics dialog.""" + process_usage = get_process_usage() + return { + "rss": process_usage.memory.rss, + "private": process_usage.memory.private, + "vms": process_usage.memory.vms, + "cpu_percent": process_usage.cpu_percent, + "threads": process_usage.threads, + "sessions": len(sessions.sessions), + "buffers": len(self.buffers), + } + + def show_diagnostics(self, *args, **kwargs): + """Show live resource diagnostics when running from source.""" + if hasattr(sys, "frozen"): + return + if self.diagnostics_dialog is None or self.diagnostics_dialog.IsBeingDeleted(): + self.diagnostics_dialog = diagnostics.DiagnosticsDialog( + self.view, + snapshot_provider=self.get_diagnostics_snapshot, + on_close=self.close_diagnostics, + ) + self.diagnostics_dialog.Show() + self.diagnostics_dialog.Raise() + + def close_diagnostics(self): + """Clear the dialog reference after it has stopped its timer.""" + self.diagnostics_dialog = None + def follow(self, *args, **kwargs): buffer = self.get_current_buffer() handler = self.get_handler(type=buffer.session.type) diff --git a/src/mysc/memory_utils.py b/src/mysc/memory_utils.py new file mode 100644 index 00000000..ecee54a9 --- /dev/null +++ b/src/mysc/memory_utils.py @@ -0,0 +1,101 @@ +"""Utilities for obtaining a lightweight memory snapshot of TWBlue.""" + +from __future__ import annotations + +import logging +import os +from dataclasses import dataclass +from typing import Optional + +import psutil + +_current_process: Optional[psutil.Process] = None + + +@dataclass(frozen=True) +class MemoryUsage: + """Memory counters for a single process, expressed in bytes.""" + + rss: int + """Resident set size (physical RAM currently used by the process).""" + + vms: int + """Virtual memory size reserved by the process.""" + + private: Optional[int] = None + """Private/unique memory when the operating system exposes it.""" + + +@dataclass(frozen=True) +class ProcessUsage: + """Runtime resource counters for a single process.""" + + memory: MemoryUsage + cpu_percent: float + threads: int + + +def get_memory_usage(process: Optional[psutil.Process] = None) -> MemoryUsage: + """Return a point-in-time memory snapshot for the current process. + + RSS is the primary value to compare when evaluating TWBlue's RAM footprint. + Private memory is included when psutil can obtain it; its exact field varies + by operating system. + """ + process = process or psutil.Process(os.getpid()) + memory_info = process.memory_info() + private_memory: Optional[int] = None + + try: + full_memory_info = process.memory_full_info() + private_memory = getattr( + full_memory_info, + "uss", + getattr(full_memory_info, "private", None), + ) + except (psutil.AccessDenied, psutil.NoSuchProcess): + pass + + return MemoryUsage( + rss=memory_info.rss, + vms=memory_info.vms, + private=private_memory, + ) + + +def get_process_usage(process: Optional[psutil.Process] = None) -> ProcessUsage: + """Return memory, CPU usage and thread count for the current process. + + ``cpu_percent`` is non-blocking and measures usage since the previous call + for the same process. Its first value is expected to be zero. + """ + global _current_process + if process is None: + if _current_process is None or _current_process.pid != os.getpid(): + _current_process = psutil.Process(os.getpid()) + process = _current_process + return ProcessUsage( + memory=get_memory_usage(process), + cpu_percent=process.cpu_percent(interval=None), + threads=process.num_threads(), + ) + + +def format_memory_usage(usage: MemoryUsage) -> str: + """Format a memory snapshot for logs or diagnostics screens.""" + parts = [ + "RSS: {:.2f} MiB".format(usage.rss / 1024**2), + "VMS: {:.2f} MiB".format(usage.vms / 1024**2), + ] + if usage.private is not None: + parts.append("Private: {:.2f} MiB".format(usage.private / 1024**2)) + return ", ".join(parts) + + +def log_memory_usage(context: str = "", logger: Optional[logging.Logger] = None) -> MemoryUsage: + """Capture and log memory usage, returning the snapshot to the caller.""" + usage = get_memory_usage() + logger = logger or logging.getLogger(__name__) + prefix = " after {}".format(context) if context else "" + logger.info("Memory usage%s: %s", prefix, format_memory_usage(usage)) + return usage diff --git a/src/test/mysc/test_memory_utils.py b/src/test/mysc/test_memory_utils.py new file mode 100644 index 00000000..bc9451ad --- /dev/null +++ b/src/test/mysc/test_memory_utils.py @@ -0,0 +1,46 @@ +from mysc.memory_utils import MemoryUsage, ProcessUsage, format_memory_usage, get_process_usage + + +def test_format_memory_usage_includes_all_available_counters(): + usage = MemoryUsage(rss=2 * 1024**2, vms=3 * 1024**2, private=1024**2) + + assert format_memory_usage(usage) == "RSS: 2.00 MiB, VMS: 3.00 MiB, Private: 1.00 MiB" + + +def test_format_memory_usage_omits_private_when_unavailable(): + usage = MemoryUsage(rss=2 * 1024**2, vms=3 * 1024**2) + + assert format_memory_usage(usage) == "RSS: 2.00 MiB, VMS: 3.00 MiB" + + +def test_process_usage_keeps_memory_cpu_and_thread_counters_together(): + memory = MemoryUsage(rss=2 * 1024**2, vms=3 * 1024**2) + usage = ProcessUsage(memory=memory, cpu_percent=12.5, threads=8) + + assert usage.memory is memory + assert usage.cpu_percent == 12.5 + assert usage.threads == 8 + + +def test_get_process_usage_collects_cpu_threads_and_memory_from_process(): + class FakeProcess: + def memory_info(self): + return type("MemoryInfo", (), {"rss": 2 * 1024**2, "vms": 3 * 1024**2})() + + def memory_full_info(self): + return type("FullMemoryInfo", (), {"uss": 1024**2})() + + def cpu_percent(self, interval): + assert interval is None + return 12.5 + + def num_threads(self): + return 8 + + usage = get_process_usage(FakeProcess()) + + assert usage == ProcessUsage( + memory=MemoryUsage(rss=2 * 1024**2, vms=3 * 1024**2, private=1024**2), + cpu_percent=12.5, + threads=8, + ) diff --git a/src/wxUI/dialogs/diagnostics.py b/src/wxUI/dialogs/diagnostics.py new file mode 100644 index 00000000..d0f7a357 --- /dev/null +++ b/src/wxUI/dialogs/diagnostics.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""Source-only runtime diagnostics dialog.""" + +import wx + + +class DiagnosticsDialog(wx.Dialog): + """Display a periodically refreshed snapshot of application resources.""" + + def __init__(self, parent, snapshot_provider, on_close): + super(DiagnosticsDialog, self).__init__(parent, title=_("Runtime diagnostics")) + self.snapshot_provider = snapshot_provider + self.on_close_callback = on_close + self.timer = wx.Timer(self) + self.values = {} + + panel = wx.Panel(self) + self.panel = panel + sizer = wx.BoxSizer(wx.VERTICAL) + grid = wx.FlexGridSizer(cols=2, hgap=12, vgap=8) + grid.AddGrowableCol(1, 1) + self._add_row(grid, "rss", _("Resident memory (RSS)")) + self._add_row(grid, "private", _("Private memory")) + self._add_row(grid, "vms", _("Virtual memory (VMS)")) + self._add_row(grid, "cpu", _("Process CPU usage")) + self._add_row(grid, "threads", _("Process threads")) + self._add_row(grid, "sessions", _("Loaded sessions")) + self._add_row(grid, "buffers", _("Application buffers")) + sizer.Add(grid, 0, wx.ALL | wx.EXPAND, 12) + + close_button = wx.Button(panel, wx.ID_CLOSE, _("&Close")) + close_button.SetDefault() + sizer.Add(close_button, 0, wx.ALL | wx.ALIGN_RIGHT, 8) + panel.SetSizer(sizer) + self.SetClientSize(sizer.CalcMin()) + self.CentreOnParent() + + self.Bind(wx.EVT_TIMER, self.refresh, self.timer) + self.Bind(wx.EVT_BUTTON, self.on_close, close_button) + self.Bind(wx.EVT_CLOSE, self.on_close) + self.refresh() + self.timer.Start(1000) + + def _add_row(self, sizer, key, label): + sizer.Add(wx.StaticText(self.panel, wx.ID_ANY, label), 0, wx.ALIGN_CENTER_VERTICAL) + value = wx.StaticText(self.panel, wx.ID_ANY, "-") + sizer.Add(value, 0, wx.ALIGN_CENTER_VERTICAL) + self.values[key] = value + + def refresh(self, event=None): + """Refresh values without creating a background monitoring task.""" + try: + snapshot = self.snapshot_provider() + except Exception: + return + + self.values["rss"].SetLabel(self._format_mib(snapshot["rss"])) + self.values["private"].SetLabel(self._format_mib(snapshot["private"])) + self.values["vms"].SetLabel(self._format_mib(snapshot["vms"])) + self.values["cpu"].SetLabel("{:.1f}%".format(snapshot["cpu_percent"])) + self.values["threads"].SetLabel(str(snapshot["threads"])) + self.values["sessions"].SetLabel(str(snapshot["sessions"])) + self.values["buffers"].SetLabel(str(snapshot["buffers"])) + + @staticmethod + def _format_mib(value): + if value is None: + return _("Not available") + return "{:.2f} MiB".format(value / 1024**2) + + def on_close(self, event): + if self.timer.IsRunning(): + self.timer.Stop() + self.on_close_callback() + self.Destroy() diff --git a/src/wxUI/view.py b/src/wxUI/view.py index 693bfe9b..f66ac290 100644 --- a/src/wxUI/view.py +++ b/src/wxUI/view.py @@ -2,6 +2,7 @@ import wx import wx.adv import application +import sys class mainFrame(wx.Frame): """ Main class of the Frame. This is the Main Window.""" @@ -79,6 +80,9 @@ class mainFrame(wx.Frame): self.visit_website = self.menubar_help.Append(-1, _(u"{0}'s &website").format(application.name,)) self.get_soundpacks = self.menubar_help.Append(-1, _(u"Get soundpacks for TWBlue")) self.about = self.menubar_help.Append(-1, _(u"About &{0}").format(application.name,)) + if not hasattr(sys, "frozen"): + self.menubar_help.AppendSeparator() + self.diagnostics = self.menubar_help.Append(wx.ID_ANY, _("Runtime &diagnostics")) # Add all to the menu Bar self.menubar.Append(self.menubar_application, _(u"&Application"))