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 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)
+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
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"))