From 9e4b5c85696100b70bd53ffb7f1223df36257cba Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:07:53 -0400 Subject: [PATCH 01/13] Fix overflow error that occurs with NumericItem by holding the data on the Python side --- PyMemoryEditor/app/_widgets.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 8257ca4..ef79613 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -66,6 +66,22 @@ 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. """ + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._data = 0 + + def setData(self, value: int, role: int = Qt.UserRole + 1): + if role >= Qt.UserRole: + self._data = value + self.emitDataChanged() + else: + super().setData(value, role) + + def data(self, role: int = Qt.UserRole + 1) -> int: + if role >= Qt.UserRole: + return self._data + else: + return super().data(role) def __lt__(self, other): try: From 467f56d6790bccb8aaff8252e668f9ef329617fe Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:08:36 -0400 Subject: [PATCH 02/13] Make addresses in memory map display as monospace so they are easier to visually compare --- PyMemoryEditor/app/memory_map_dialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py index f3caa53..628ca68 100644 --- a/PyMemoryEditor/app/memory_map_dialog.py +++ b/PyMemoryEditor/app/memory_map_dialog.py @@ -19,7 +19,7 @@ from typing import List, Optional from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel +from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel, QFontDatabase from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, @@ -391,6 +391,7 @@ def _populate(self) -> None: shown += 1 addr_item = NumericItem(f"0x{addr:016X}") + addr_item.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) addr_item.setData(addr, Qt.UserRole) size_item = NumericItem(_format_size(size)) From f78ce09a0e314d84d5a8cb3e49fcf53cdbf05002 Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:14:14 -0400 Subject: [PATCH 03/13] Make addresses in module viewer display as monospace so they are easier to visually compare --- PyMemoryEditor/app/modules_dialog.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/PyMemoryEditor/app/modules_dialog.py b/PyMemoryEditor/app/modules_dialog.py index 89670ae..b4ae5ad 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 QGuiApplication, QStandardItem, QStandardItemModel, QFontDatabase from PySide6.QtWidgets import ( QAbstractItemView, QHBoxLayout, @@ -167,6 +167,7 @@ def _apply_filter(self) -> None: base = int(module.base_address) base_item = NumericItem(f"0x{base:016X}") + base_item.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) base_item.setData(base, Qt.UserRole) size = int(module.size) From 9386f0b3953170147d25ddec510ed6f8fe7fba0c Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:13:37 -0400 Subject: [PATCH 04/13] Use dict for NumericItem role storage. Update types and docstring --- PyMemoryEditor/app/_widgets.py | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index ef79613..217a75b 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -6,7 +6,7 @@ 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 @@ -65,29 +65,35 @@ 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 for QStandardItem is overridden because the Pyside6 + bindings do not seem to support, detect, or coerce to unsigned integers, + leading to overflow errors when converting from large Python ints to fixed + size signed integers in Qt. The workaround is to keep the potentially overflowing + integers on the Python side. """ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self._data = 0 + self._user_data: dict[int, Any] = {} - def setData(self, value: int, role: int = Qt.UserRole + 1): + def setData(self, value: Any, role: int = Qt.UserRole + 1): if role >= Qt.UserRole: - self._data = value + self._user_data[int(role)] = value self.emitDataChanged() else: super().setData(value, role) - def data(self, role: int = Qt.UserRole + 1) -> int: + def data(self, role: int = Qt.UserRole + 1) -> Any: if role >= Qt.UserRole: - return self._data + return self._user_data[int(role)] else: return super().data(role) - def __lt__(self, other): + def __lt__(self, other: QStandardItem) -> bool: try: return int(self.data(Qt.UserRole)) < int(other.data(Qt.UserRole)) except (TypeError, ValueError): - return super().__lt__(other) + return self.text() < other.text() def parse_hex_address(text: str) -> Optional[int]: From 53fc977733c9548569114874862d5f70b592f54d Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:14:45 -0400 Subject: [PATCH 05/13] Update monospace font assignment for memory map and modules widgets --- PyMemoryEditor/app/memory_map_dialog.py | 8 +++++--- PyMemoryEditor/app/modules_dialog.py | 8 +++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py index 628ca68..7983c5f 100644 --- a/PyMemoryEditor/app/memory_map_dialog.py +++ b/PyMemoryEditor/app/memory_map_dialog.py @@ -19,7 +19,7 @@ from typing import List, Optional from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel, QFontDatabase +from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, @@ -38,7 +38,7 @@ from ._auto_refresh_dialog import AutoRefreshTableDialog from ._widgets import NumericItem - +from .pointer_scan_dialog import _MONO def _format_size(size: int) -> str: units = ["B", "KB", "MB", "GB", "TB"] @@ -380,6 +380,8 @@ def _populate(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) + mono_font = QFont(_MONO, 10) + shown = 0 for region in self._snapshot: addr = int(region.address) @@ -391,7 +393,7 @@ def _populate(self) -> None: shown += 1 addr_item = NumericItem(f"0x{addr:016X}") - addr_item.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + 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 b4ae5ad..4713a8d 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, QFontDatabase +from PySide6.QtGui import QFont, QGuiApplication, QStandardItem, QStandardItemModel from PySide6.QtWidgets import ( QAbstractItemView, QHBoxLayout, @@ -40,7 +40,7 @@ from ._auto_refresh_dialog import AutoRefreshTableDialog from ._widgets import NumericItem from .memory_map_dialog import _format_size - +from .pointer_scan_dialog import _MONO class ModulesDialog(AutoRefreshTableDialog): """Shows the output of ``get_modules()`` in a sortable, filterable table.""" @@ -157,6 +157,8 @@ def _apply_filter(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) + mono_font = QFont(_MONO, 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,7 +169,7 @@ def _apply_filter(self) -> None: base = int(module.base_address) base_item = NumericItem(f"0x{base:016X}") - base_item.setFont(QFontDatabase.systemFont(QFontDatabase.SystemFont.FixedFont)) + base_item.setFont(mono_font) base_item.setData(base, Qt.UserRole) size = int(module.size) From 00b9fa0c6d75afe76ade650b012487ec931662fe Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:40:00 -0400 Subject: [PATCH 06/13] Linter fixes --- PyMemoryEditor/app/memory_map_dialog.py | 1 + PyMemoryEditor/app/modules_dialog.py | 1 + 2 files changed, 2 insertions(+) diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py index 7983c5f..6cd9299 100644 --- a/PyMemoryEditor/app/memory_map_dialog.py +++ b/PyMemoryEditor/app/memory_map_dialog.py @@ -40,6 +40,7 @@ from ._widgets import NumericItem from .pointer_scan_dialog import _MONO + def _format_size(size: int) -> str: units = ["B", "KB", "MB", "GB", "TB"] s = float(size) diff --git a/PyMemoryEditor/app/modules_dialog.py b/PyMemoryEditor/app/modules_dialog.py index 4713a8d..b4d4462 100644 --- a/PyMemoryEditor/app/modules_dialog.py +++ b/PyMemoryEditor/app/modules_dialog.py @@ -42,6 +42,7 @@ from .memory_map_dialog import _format_size from .pointer_scan_dialog import _MONO + class ModulesDialog(AutoRefreshTableDialog): """Shows the output of ``get_modules()`` in a sortable, filterable table.""" From 9a8adc00e07ebb2e378d4a77bccceab9cdf5306d Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:41:53 -0400 Subject: [PATCH 07/13] Add tests for _widgets, starting with NumericItem --- tests/app/test_app_smoke.py | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index d8b6053..0f211dd 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -162,3 +162,32 @@ def test_pointer_scan_dialog_constructs_and_prefills(qtbot): dialog.close() finally: process.close() + + +@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") +def test_pyside_widget_regressions(qtbot): + """ + Test for Pyside related regressions, like potential overflow and comparison in NumericItem. + """ + + 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 + + # Segmentation fault regression (recursive stack overflow). + item < item2 + + item3 = _widgets.NumericItem() + item3.setData('123456') + + item < item3 From 4e01f7ae476e286aa913826b4baf42e9aafd6512 Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:52:30 -0400 Subject: [PATCH 08/13] Make UserRole the default role key instead of UserRole+1, since this would be the default of __lt__ --- PyMemoryEditor/app/_widgets.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 217a75b..3a88a5f 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -76,22 +76,22 @@ def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._user_data: dict[int, Any] = {} - def setData(self, value: Any, role: int = Qt.UserRole + 1): + 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 + 1) -> Any: + def data(self, role: int = Qt.UserRole) -> Any: if role >= Qt.UserRole: - return self._user_data[int(role)] + 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 self.text() < other.text() From b5d070e90e55c2ad4618d4202d8c67d713e485d5 Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:53:07 -0400 Subject: [PATCH 09/13] Add text override to NumericItem to try to pull from the stored dict first. --- PyMemoryEditor/app/_widgets.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 3a88a5f..da4f6bf 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -89,6 +89,12 @@ def data(self, role: int = Qt.UserRole) -> Any: else: return super().data(role) + def text(self) -> str: + try: + return str(self.data()) + except (KeyError): + return super().text() + def __lt__(self, other: QStandardItem) -> bool: try: return int(self.data()) < int(other.data()) From 08ea8c598abc99a615f9f6a1d581c81a116d60fe Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Sun, 2 Aug 2026 14:53:34 -0400 Subject: [PATCH 10/13] Add more coverage to NumericItem tests --- tests/app/test_app_smoke.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 0f211dd..5514d55 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -189,5 +189,7 @@ def test_pyside_widget_regressions(qtbot): item3 = _widgets.NumericItem() item3.setData('123456') + item < item3 + item3.setData('hello world') item < item3 From a82087b8cf3d42907c2aebbf839664794d83930b Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 2 Aug 2026 23:18:30 -0300 Subject: [PATCH 11/13] fix(app): drop NumericItem.text() override and assert the regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The override returned str(self.data()), so text() stopped matching what the table displays (the raw payload instead of the formatted label, and 'None' for rows without one) and the __lt__ fallback compared 'None' to 'None' rather than the labels. Its except branch was unreachable too: dict.get() returns None, it never raises KeyError. QStandardItem.text() already returns the label the fallback wants. The NumericItem tests were only checking that nothing crashed. Assert the comparison results, and cover the two paths that weren't exercised: user roles staying distinct, and the C++ sort driving comparisons over a column that mixes payloads with None — the case that segfaulted, which comparing two items directly doesn't reach. --- PyMemoryEditor/app/_widgets.py | 6 ------ tests/app/test_app_smoke.py | 34 ++++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 12 deletions(-) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index da4f6bf..3a88a5f 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -89,12 +89,6 @@ def data(self, role: int = Qt.UserRole) -> Any: else: return super().data(role) - def text(self) -> str: - try: - return str(self.data()) - except (KeyError): - return super().text() - def __lt__(self, other: QStandardItem) -> bool: try: return int(self.data()) < int(other.data()) diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 5514d55..2886cce 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -184,12 +184,34 @@ def test_pyside_widget_regressions(qtbot): item2.setData(big_number) assert item2.data() == big_number - # Segmentation fault regression (recursive stack overflow). - item < item2 + from PySide6.QtCore import Qt + from PySide6.QtGui import QStandardItemModel - item3 = _widgets.NumericItem() - item3.setData('123456') - item < item3 + # 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') - item < item3 + 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') From 57bb6ad9309dedd6c09631d8a1f926473b3c1264 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 2 Aug 2026 23:26:39 -0300 Subject: [PATCH 12/13] refactor(app): centralise the monospace family and split the widget tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The address columns were reaching into pointer_scan_dialog for its private _MONO, which points the dependency the wrong way: the memory map pulled in the whole pointer scan module for one string. Move the family to _widgets, next to the NumericItem those columns already import, and have all three dialogs read it from there. The memory map's size field was hardcoding the same stack, so it reads the constant now too. Move the NumericItem tests out of the smoke file into test_app_widgets.py. They were gated behind a pytest-qt skip they never needed — none of them uses qtbot, a QApplication is enough — so they now run wherever PySide6 is installed. --- PyMemoryEditor/app/_widgets.py | 5 ++ PyMemoryEditor/app/memory_map_dialog.py | 7 +- PyMemoryEditor/app/modules_dialog.py | 5 +- PyMemoryEditor/app/pointer_scan_dialog.py | 11 ++- tests/app/test_app_smoke.py | 53 -------------- tests/app/test_app_widgets.py | 87 +++++++++++++++++++++++ 6 files changed, 105 insertions(+), 63 deletions(-) create mode 100644 tests/app/test_app_widgets.py diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 3a88a5f..0574989 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -12,6 +12,11 @@ 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 diff --git a/PyMemoryEditor/app/memory_map_dialog.py b/PyMemoryEditor/app/memory_map_dialog.py index 6cd9299..ee28435 100644 --- a/PyMemoryEditor/app/memory_map_dialog.py +++ b/PyMemoryEditor/app/memory_map_dialog.py @@ -37,8 +37,7 @@ from PyMemoryEditor import AbstractProcess, MemoryRegion, MemoryRegionSnapshot from ._auto_refresh_dialog import AutoRefreshTableDialog -from ._widgets import NumericItem -from .pointer_scan_dialog import _MONO +from ._widgets import MONOSPACE_FAMILY, NumericItem def _format_size(size: int) -> str: @@ -266,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) @@ -381,7 +380,7 @@ def _populate(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) - mono_font = QFont(_MONO, 10) + mono_font = QFont(MONOSPACE_FAMILY, 10) shown = 0 for region in self._snapshot: diff --git a/PyMemoryEditor/app/modules_dialog.py b/PyMemoryEditor/app/modules_dialog.py index b4d4462..3254778 100644 --- a/PyMemoryEditor/app/modules_dialog.py +++ b/PyMemoryEditor/app/modules_dialog.py @@ -38,9 +38,8 @@ 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 -from .pointer_scan_dialog import _MONO class ModulesDialog(AutoRefreshTableDialog): @@ -158,7 +157,7 @@ def _apply_filter(self) -> None: self._table.setSortingEnabled(False) self._model.setRowCount(0) - mono_font = QFont(_MONO, 10) + mono_font = QFont(MONOSPACE_FAMILY, 10) shown = 0 for module in self._modules: 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_smoke.py b/tests/app/test_app_smoke.py index 2886cce..d8b6053 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -162,56 +162,3 @@ def test_pointer_scan_dialog_constructs_and_prefills(qtbot): dialog.close() finally: process.close() - - -@pytest.mark.skipif(not qtbot_available, reason="pytest-qt not installed.") -def test_pyside_widget_regressions(qtbot): - """ - Test for Pyside related regressions, like potential overflow and comparison in NumericItem. - """ - - 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 - - from PySide6.QtCore import Qt - from PySide6.QtGui import QStandardItemModel - - # 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') 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') From 9fcfc0ae9044dd9fd22e557ca818dddd6e8a2fff Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Sun, 2 Aug 2026 23:38:42 -0300 Subject: [PATCH 13/13] docs(app): correct the NumericItem overflow rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cap isn't about unsigned support — a plain QStandardItem takes 0x7FFDABCD1234 without complaint and only breaks past 2**63, which is QVariant's qint64 limit. Name the actual trigger ([vsyscall] at 0xffffffffff600000) and the constraint the workaround creates: the payload never reaches the C++ model, so reading it back through model.data() hits the same OverflowError. --- PyMemoryEditor/app/_widgets.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/PyMemoryEditor/app/_widgets.py b/PyMemoryEditor/app/_widgets.py index 0574989..d0f5d1a 100644 --- a/PyMemoryEditor/app/_widgets.py +++ b/PyMemoryEditor/app/_widgets.py @@ -71,12 +71,19 @@ 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 for QStandardItem is overridden because the Pyside6 - bindings do not seem to support, detect, or coerce to unsigned integers, - leading to overflow errors when converting from large Python ints to fixed - size signed integers in Qt. The workaround is to keep the potentially overflowing - integers on the Python side. + 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 __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._user_data: dict[int, Any] = {}