feat: add source runtime diagnostics

This commit is contained in:
2026-08-12 14:07:16 -06:00
parent 0612f85b21
commit 3b86a70a4c
5 changed files with 265 additions and 0 deletions
+39
View File
@@ -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)
+101
View File
@@ -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
+46
View File
@@ -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,
)
+75
View File
@@ -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()
+4
View File
@@ -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"))