Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 38 additions & 4 deletions PyMemoryEditor/app/_widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Comment thread
JeanExtreme002 marked this conversation as resolved.

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]:
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/memory_map_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -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))
Expand Down
7 changes: 5 additions & 2 deletions PyMemoryEditor/app/modules_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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


Expand Down Expand Up @@ -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():
Expand All @@ -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)
Expand Down
11 changes: 8 additions & 3 deletions PyMemoryEditor/app/pointer_scan_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
87 changes: 87 additions & 0 deletions tests/app/test_app_widgets.py
Original file line number Diff line number Diff line change
@@ -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')
Loading