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 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)
|
||||
|
||||
@@ -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.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"))
|
||||
|
||||
Reference in New Issue
Block a user