diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 8257ca4..d0f5d1a 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -6,12 +6,17 @@ previously appeared duplicated across several dialog modules. """ -from typing import Callable, Iterable, List, Optional, Tuple +from typing import Any, Callable, Iterable, List, Optional, Tuple from PySide6.QtCore import Qt, QThread from PySide6.QtGui import QStandardItem +# Monospace stack used across the app for address/value text. An explicit +# family list rather than the platform's default fixed font, so every table +# renders addresses at the same family and size on every OS. +MONOSPACE_FAMILY = "Menlo, Consolas, Courier New" + # Workers that wouldn't stop in time on close are parked here so they are never # destroyed while still running (that aborts the whole process with # "QThread: Destroyed while thread is still running"). The list is module-level @@ -65,13 +70,42 @@ class NumericItem(QStandardItem): Used by columns showing formatted numbers (sizes, addresses, PIDs) so the table sorts by the underlying value rather than the lexical label. + + The data storage interface is overridden because Qt keeps item data in a + QVariant, whose integers cap at qint64. Values past 2**63 can't make that + conversion — Linux x86-64 maps [vsyscall] at 0xffffffffff600000, so the + memory map hands one such address to the C++ side and gets "OverflowError: + int too big to convert", leaving the table half-populated. The workaround + is to keep user-role payloads on the Python side, where an int is an int. + + The flip side: those payloads never reach the C++ model, so read them off + the item (``item.data(role)``) and never through ``model.data(index, + role)`` — that path converts the value back into a QVariant and overflows + all over again. """ - def __lt__(self, other): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._user_data: dict[int, Any] = {} + + def setData(self, value: Any, role: int = Qt.UserRole): + if role >= Qt.UserRole: + self._user_data[int(role)] = value + self.emitDataChanged() + else: + super().setData(value, role) + + def data(self, role: int = Qt.UserRole) -> Any: + if role >= Qt.UserRole: + return self._user_data.get(int(role)) + else: + return super().data(role) + + def __lt__(self, other: QStandardItem) -> bool: try: - return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole)) + return int(self.data()) < int(other.data()) except (TypeError, ValueError): - return super().__lt__(other) + return self.text() < other.text() def parse_hex_address(text: str) -> Optional[int]: diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py index f3caa53..ee28435 100644 --- a/PyMemoryEditor/app/memory_map_dialog.py +++ b/PyMemoryEditor/app/memory_map_dialog.py @@ -37,7 +37,7 @@ from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot from ._auto_refresh_dialog import AutoRefreshTableDialog -from ._widgets import NumericItem +from ._widgets import MONOSPACE_FAMILY, NumericItem def _format_size(size: int) -> str: @@ -265,7 +265,7 @@ def _build_ui(self) -> None: self._size_edit = QLineEdit() self._size_edit.setPlaceholderText("amount") - self._size_edit.setFont(QFont("Menlo, Consolas, Courier New", 10)) + self._size_edit.setFont(QFont(MONOSPACE_FAMILY, 10)) self._size_edit.setFixedWidth(140) self._size_edit.returnPressed.connect(self._on_allocate) footer.addWidget(self._size_edit) @@ -380,6 +380,8 @@ def _populate(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) + mono_font = QFont(MONOSPACE_FAMILY, 10) + shown = 0 for region in self._snapshot: addr = int(region.address) @@ -391,6 +393,7 @@ def _populate(self) -> None: shown += 1 addr_item = NumericItem(f"0x{addr:016X}") + addr_item.setFont(mono_font) addr_item.setData(addr, Qt.UserRole) size_item = NumericItem(_format_size(size)) diff --git a/PyMemoryEditor/app/modules_dialog.py b/PyMemoryEditor/app/modules_dialog.py index 89670ae..3254778 100644 --- a/PyMemoryEditor/app/modules_dialog.py +++ b/PyMemoryEditor/app/modules_dialog.py @@ -21,7 +21,7 @@ from typing import List, Optional from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QGuiApplication, QStandardItem, QStandardItemModel +from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QAbstractItemView, QHBoxLayout, @@ -38,7 +38,7 @@ from PyMemoryEditor import AbstractProcess, ModuleInfo from ._auto_refresh_dialog import AutoRefreshTableDialog -from ._widgets import NumericItem +from ._widgets import MONOSPACE_FAMILY, NumericItem from .memory_map_dialog import _format_size @@ -157,6 +157,8 @@ def _apply_filter(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) + mono_font = QFont(MONOSPACE_FAMILY, 10) + shown = 0 for module in self._modules: if needle and needle not in module.name.lower() and needle not in module.path.lower(): @@ -167,6 +169,7 @@ def _apply_filter(self) -> None: base = int(module.base_address) base_item = NumericItem(f"0x{base:016X}") + base_item.setFont(mono_font) base_item.setData(base, Qt.UserRole) size = int(module.size) diff --git a/PyMemoryEditor/app/pointer_scan_dialog.py b/PyMemoryEditor/app/pointer_scan_dialog.py index ea4fe35..70db079 100644 --- a/PyMemoryEditor/app/pointer_scan_dialog.py +++ b/PyMemoryEditor/app/pointer_scan_dialog.py @@ -52,14 +52,19 @@ from PyMemoryEditor import AbstractProcess, PointerPath from PyMemoryEditor.process.pointer_scan import intersect_pointer_paths -from ._widgets import NumericItem, parse_hex_address, shutdown_worker_thread +from ._widgets import ( + MONOSPACE_FAMILY, + NumericItem, + parse_hex_address, + shutdown_worker_thread, +) from .value_types import VALUE_TYPES, ValueTypeSpec, find_spec _LOG = logging.getLogger(__name__) -# Monospace stack used elsewhere in the app for address/value text. -_MONO = "Menlo, Consolas, Courier New" +# Short alias for the app-wide monospace stack (used on every row built here). +_MONO = MONOSPACE_FAMILY # Stream resolved paths to the table in batches this size, so a scan that finds # thousands of paths updates the UI smoothly instead of one row at a time. diff --git a/tests/app/test_app_widgets.py b/tests/app/test_app_widgets.py new file mode 100644 index 0000000..d95d507 --- /dev/null +++ b/tests/app/test_app_widgets.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- + +""" +Tests for the shared item widgets in ``PyMemoryEditor/app/_widgets.py``. + +Only ``NumericItem`` so far, whose sort payload can't live in a ``QVariant``: +Qt caps those integers at ``qint64``, but Linux x86-64 maps ``[vsyscall]`` at +0xffffffffff600000 (above 2**63), so pushing that address through +``QStandardItem.setData`` raises "OverflowError: int too big to convert" and +leaves the memory map half-populated. + +Unlike the dialog tests these need no ``qtbot`` — a ``QApplication`` is enough, +so they keep running when ``pytest-qt`` isn't installed. + +Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via +the ``app`` extra). +""" + +import os + +import pytest + + +pytest.importorskip("PySide6", reason="App tests require PySide6 (install with [app] extra).") + +# Offscreen platform plugin: no display server needed, runs on CI. +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + + +@pytest.fixture(scope="module") +def qapp(): + """A single QApplication for the module (Qt allows only one per process).""" + from PySide6.QtWidgets import QApplication + + return QApplication.instance() or QApplication([]) + + +def test_pyside_widget_regressions(qapp): + """ + Test for Pyside related regressions, like potential overflow and comparison in NumericItem. + """ + + from PySide6.QtCore import Qt + from PySide6.QtGui import QStandardItemModel + + from PyMemoryEditor.app import _widgets + + unsigned_64bit_max = 0xffff_ffff_ffff_ffff + big_number = 2 ** 128 + + # Overflow regressions. + item = _widgets.NumericItem() + item.setData(unsigned_64bit_max) + assert item.data() == unsigned_64bit_max + + item2 = _widgets.NumericItem() + item2.setData(big_number) + assert item2.data() == big_number + + # Non-numeric payloads must fall back to the labels instead of recursing + # into QStandardItem::operator< (that recursion segfaulted mid-sort). + assert item < item2 + + item3 = _widgets.NumericItem('aaa') + item3.setData('hello world') + item4 = _widgets.NumericItem('bbb') + item4.setData('hello world') + assert item3 < item4 + assert not (item4 < item3) + + # Distinct user roles must not share a slot. + item5 = _widgets.NumericItem() + item5.setData(111, Qt.UserRole) + item5.setData(222, Qt.UserRole + 1) + assert item5.data(Qt.UserRole) == 111 + assert item5.data(Qt.UserRole + 1) == 222 + + # The path that actually crashed: the C++ sort driving the comparisons over + # a column mixing payloads and None (the process picker's memory column). + model = QStandardItemModel() + for label, payload in (('120 MB', 120), ('-', None), ('8 MB', 8), ('-', None)): + row_item = _widgets.NumericItem(label) + row_item.setData(payload, Qt.UserRole) + model.appendRow([row_item]) + model.sort(0, Qt.AscendingOrder) + order = [model.item(row, 0).data(Qt.DisplayRole) for row in range(model.rowCount())] + assert order.index('8 MB') < order.index('120 MB')