mirror of
https://github.com/MCV-Software/TWBlue.git
synced 2026-08-17 18:38:11 +02:00
feat: add source runtime diagnostics
This commit is contained in:
@@ -18,6 +18,7 @@ from pubsub import pub
|
|||||||
from extra import SoundsTutorial
|
from extra import SoundsTutorial
|
||||||
from update import updater
|
from update import updater
|
||||||
from wxUI import view, dialogs, commonMessageDialogs, sysTrayIcon
|
from wxUI import view, dialogs, commonMessageDialogs, sysTrayIcon
|
||||||
|
from wxUI.dialogs import diagnostics
|
||||||
from keyboard_handler.wx_handler import WXKeyboardHandler
|
from keyboard_handler.wx_handler import WXKeyboardHandler
|
||||||
from sessionmanager import manager, sessionManager
|
from sessionmanager import manager, sessionManager
|
||||||
from controller import buffers
|
from controller import buffers
|
||||||
@@ -25,6 +26,7 @@ from mysc import restart
|
|||||||
from mysc import localization
|
from mysc import localization
|
||||||
from mysc.thread_utils import call_threaded
|
from mysc.thread_utils import call_threaded
|
||||||
from mysc.repeating_timer import RepeatingTimer
|
from mysc.repeating_timer import RepeatingTimer
|
||||||
|
from mysc.memory_utils import get_process_usage
|
||||||
from controller.mastodon import handler as MastodonHandler
|
from controller.mastodon import handler as MastodonHandler
|
||||||
from controller.blueski import handler as BlueskiHandler # Added import
|
from controller.blueski import handler as BlueskiHandler # Added import
|
||||||
from . import settings, userAlias
|
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.create_filter, self.view.filter)
|
||||||
widgetUtils.connect_event(self.view, widgetUtils.MENU, self.manage_filters, self.view.manage_filters)
|
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):
|
def set_systray_icon(self):
|
||||||
self.systrayIcon = sysTrayIcon.SysTrayIcon()
|
self.systrayIcon = sysTrayIcon.SysTrayIcon()
|
||||||
widgetUtils.connect_event(self.systrayIcon, widgetUtils.MENU, self.post_tweet, menuitem=self.systrayIcon.post)
|
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 = ""
|
self.menubar_current_handler = ""
|
||||||
# Handlers are special objects as they manage the mapping of available features and events in different social networks.
|
# Handlers are special objects as they manage the mapping of available features and events in different social networks.
|
||||||
self.handlers = dict()
|
self.handlers = dict()
|
||||||
|
self.diagnostics_dialog = None
|
||||||
self.view.prepare()
|
self.view.prepare()
|
||||||
self.bind_other_events()
|
self.bind_other_events()
|
||||||
self.set_systray_icon()
|
self.set_systray_icon()
|
||||||
@@ -620,6 +626,9 @@ class Controller(object):
|
|||||||
self.exit_()
|
self.exit_()
|
||||||
|
|
||||||
def exit_(self, *args, **kwargs):
|
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()
|
for i in self.buffers: i.save_positions()
|
||||||
log.debug("Exiting...")
|
log.debug("Exiting...")
|
||||||
log.debug("Saving global configuration...")
|
log.debug("Saving global configuration...")
|
||||||
@@ -635,6 +644,36 @@ class Controller(object):
|
|||||||
os.remove(pidpath)
|
os.remove(pidpath)
|
||||||
widgetUtils.exit_application()
|
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):
|
def follow(self, *args, **kwargs):
|
||||||
buffer = self.get_current_buffer()
|
buffer = self.get_current_buffer()
|
||||||
handler = self.get_handler(type=buffer.session.type)
|
handler = self.get_handler(type=buffer.session.type)
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import wx
|
import wx
|
||||||
import wx.adv
|
import wx.adv
|
||||||
import application
|
import application
|
||||||
|
import sys
|
||||||
|
|
||||||
class mainFrame(wx.Frame):
|
class mainFrame(wx.Frame):
|
||||||
""" Main class of the Frame. This is the Main Window."""
|
""" 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.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.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,))
|
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
|
# Add all to the menu Bar
|
||||||
self.menubar.Append(self.menubar_application, _(u"&Application"))
|
self.menubar.Append(self.menubar_application, _(u"&Application"))
|
||||||
|
|||||||
Reference in New Issue
Block a user