Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
141 changes: 127 additions & 14 deletions PyMemoryEditor/app/_auto_refresh_dialog.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -10,13 +10,24 @@
that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
only what differs: how to fetch the data, and how to render it.

It also owns *when* a failure is worth interrupting the user — see
:meth:`AutoRefreshTableDialog._handle_failed`.
"""
from typing import Callable, Optional

from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtCore import QElapsedTimer, QThread, QTimer, Signal
from PySide6.QtWidgets import QDialog

from ._widgets import shutdown_worker_thread
from ._widgets import TearsDownOnClose, shutdown_worker_thread


# How long a target may keep failing before the auto-refresh gives up. Not
# every failure is terminal (``CreateToolhelp32Snapshot`` is documented to fail
# transiently while a target loads images), so a blip must not freeze a window
# that would recover next tick. In wall-clock, not ticks: the intervals differ
# by more than 10x across the windows that share this budget.
_FAILURE_GRACE_MS = 3000


class _DataWorker(QThread):
Expand All@@ -38,7 +49,7 @@ def run(self) -> None:
self.ready.emit(data)


class AutoRefreshTableDialog(QDialog):
class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
"""Owns the fetch-worker lifecycle + auto-refresh timer for a table dialog.

Subclasses must:
Expand All@@ -57,6 +68,13 @@ def __init__(self, process, *, refresh_interval_ms: int, parent=None):
self._has_data = False
self._refresh_interval_ms = refresh_interval_ms
self._auto_timer: Optional[QTimer] = None
# Failure state, all reset by a success or an explicit refresh(). See
# _handle_failed for how they decide what reaches the user.
self._last_error: Optional[str] = None # doubles as "already reported"
self._failing_since: Optional[QElapsedTimer] = None # streak clock
# Read by subclasses that rebuild their status line on user input, so
# the "stopped" note isn't replaced by a count that looks live.
self._polling_gave_up = False

def _start_auto_refresh(self) -> None:
"""Begin polling. Call once, after the first ``refresh()``.
Expand All@@ -67,7 +85,7 @@ def _start_auto_refresh(self) -> None:
"""
self._auto_timer = QTimer(self)
self._auto_timer.setInterval(self._refresh_interval_ms)
self._auto_timer.timeout.connect(self.refresh)
self._auto_timer.timeout.connect(self._auto_refresh_tick)
self._auto_timer.start()

# ------------------------------------------------------------------ #
Expand All@@ -83,17 +101,55 @@ def _on_data_ready(self, data) -> None:
raise NotImplementedError

def _on_data_failed(self, message: str) -> None:
"""Report a failed fetch. Runs on the UI thread."""
"""Report a failed fetch. Runs on the UI thread.

Called only for a failure the user needs to see — the first fetch, or
the one that makes the poll give up — and never twice for the same
error (see :meth:`_handle_failed`), so a modal is safe here.
"""
raise NotImplementedError

def _set_loading_hint(self) -> None:
"""Optionally show a one-time loading message before the first fetch."""

def _on_polling_stopped(self) -> None:
"""Note that the auto-refresh gave up. Runs on the UI thread.

Fires once, when a target has been failing for ``_FAILURE_GRACE_MS``.
The table is frozen on its last good data from here on, so subclasses
should say so. Optional.
"""

# ------------------------------------------------------------------ #
# Lifecycle (shared)
# ------------------------------------------------------------------ #

def refresh(self) -> None:
"""Fetch now, on the user's behalf.

Clears the error latch and restarts a poll that repeated failures
stopped. The timer goes through :meth:`_auto_refresh_tick` instead,
which must *not* re-arm — and neither may a dismissed dialog, whose
teardown latch is one-way.
"""
if self._is_dismissed():
return
self._last_error = None
self._failing_since = None
self._polling_gave_up = False
if self._auto_timer is not None and not self._auto_timer.isActive():
self._auto_timer.start()
self._start_fetch()

def _auto_refresh_tick(self) -> None:
"""Timer-driven fetch — never re-arms after a failure."""
self._start_fetch()

def _start_fetch(self) -> None:
# A worker started after the teardown could never be joined again.
if self._is_dismissed():
return

# Skip if a fetch is already in flight — the timer would otherwise stack
# workers on a slow target. This self-throttles to the real fetch time.
if self._worker is not None and self._worker.isRunning():
Expand All@@ -106,26 +162,83 @@ def refresh(self) -> None:

worker = _DataWorker(self._fetch_data, self)
worker.ready.connect(self._handle_ready)
worker.failed.connect(self._on_data_failed)
worker.finished.connect(self._on_worker_finished)
worker.failed.connect(self._handle_failed)
worker.finished.connect(lambda w=worker: self._on_worker_finished(w))
self._worker = worker
worker.start()

def _handle_ready(self, data) -> None:
if self._is_dismissed():
return
self._has_data = True
self._last_error = None
self._failing_since = None
self._on_data_ready(data)

def _on_worker_finished(self) -> None:
worker = self._worker
self._worker = None
if worker is not None:
worker.deleteLater()
def _handle_failed(self, message: str) -> None:
"""Handle a failed fetch: report it only when the user needs to know.

Once the target is gone (or its handle was closed by File → Change
Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
dialog. A modal runs a nested event loop, so the timer fires again while
it is up and the failures stack — hundreds of focus-stealing dialogs the
user could only escape by killing the app from another TTY (issue #74).

Reporting is therefore tied to the two moments where a failure changes
what the user sees: the **first** fetch (empty window) and the one that
makes the poll **give up** (frozen table). Everything in between heals
quietly — and that silence is load-bearing: reporting every failure
re-opens the spam, since a target failing on alternating ticks stacks a
modal per failure until the stack runs out.

A dismissed dialog reports nothing. The teardown disconnects the worker,
but a delivery already queued then must not raise a modal over a window
that is gone — during ``MainWindow.closeEvent`` that would be a nested
event loop in the middle of teardown.
"""
if self._is_dismissed():
return

if self._failing_since is None:
self._failing_since = QElapsedTimer()
self._failing_since.start()
gave_up = False
else:
gave_up = (
self._failing_since.elapsed() >= _FAILURE_GRACE_MS
and self._auto_timer is not None
and self._auto_timer.isActive()
)

if gave_up and self._auto_timer is not None:
self._auto_timer.stop()
self._polling_gave_up = True

never_reported_yet = not self._has_data and self._last_error is None
if (gave_up or never_reported_yet) and message != self._last_error:
self._last_error = message
self._on_data_failed(message)

# After the report, so the "stopped" note is what stays on screen.
if gave_up:
self._on_polling_stopped()

def _on_worker_finished(self, worker) -> None:
"""Retire the worker that just finished — and only that one.

``finished`` is queued, so it can arrive after the next tick already
started a replacement. Clearing ``self._worker`` blindly would drop the
reference to that *running* worker and ``deleteLater()`` it — destroying
a live QThread aborts the process.
"""
if worker is self._worker:
self._worker = None
worker.deleteLater()

def closeEvent(self, event): # noqa: N802 — Qt naming
def _teardown(self) -> None:
if self._auto_timer is not None:
self._auto_timer.stop()
# Unhook + join the worker; if it can't stop in time it's detached
# rather than destroyed under us.
shutdown_worker_thread(self._worker, wait_ms=1000)
self._worker = None
super().closeEvent(event)
68 changes: 63 additions & 5 deletions PyMemoryEditor/app/_widgets.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -8,7 +8,7 @@

from typing import Any, Callable, Iterable, List, Optional, Tuple

from PySide6.QtCore import Qt, QThread
from PySide6.QtCore import QObject, Qt, QThread
from PySide6.QtGui import QStandardItem


Expand DownExpand Up@@ -44,27 +44,85 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
if callable(cancel):
cancel()
try:
worker.disconnect() # drop every outgoing connection at once
# The four-argument static form on purpose: bare `worker.disconnect()`
# raises TypeError in PySide6 (so this except swallowed it and nothing
# was disconnected), and `worker.disconnect(worker)` only drops
# connections whose *receiver* is the worker — ours belong to the dialog.
QObject.disconnect(worker, None, None, None)
except (RuntimeError, TypeError):
pass

if worker.isRunning():
worker.wait(wait_ms)

if worker.isRunning():
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
detach_worker(worker)
else:
worker.deleteLater()


def detach_worker(worker: QThread) -> None:
"""Park a worker that wouldn't stop, so its owner can be destroyed.

Reparents it away from the dying widget and holds it in the module-level
registry until it finishes on its own. Callers that stop workers their own
way park them here too, so threads that outlived their owner are all in one
place.
"""
worker.setParent(None)
_DETACHED_WORKERS.append(worker)
worker.finished.connect(lambda: _reap_detached_worker(worker))
# A worker that finished before this connect already emitted, so the reaper
# would never run and it would sit here for good.
if worker.isFinished():
_reap_detached_worker(worker)


def _reap_detached_worker(worker: QThread) -> None:
if worker in _DETACHED_WORKERS:
_DETACHED_WORKERS.remove(worker)
worker.deleteLater()


class TearsDownOnClose:
"""Mixin that runs a dialog's teardown on *every* way out, not just close.

``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
"Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
``done()``, which hides the dialog and emits ``finished`` *without* any
close event — so a teardown living in ``closeEvent`` never ran, and the
dialog kept polling, hidden and unreachable.

Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
whichever way the dialog is dismissed, and must precede ``QDialog`` in the
bases so its overrides win. Running once makes instances single-use, which
is what every caller here assumes.
"""

def _teardown(self) -> None:
"""Release timers and background threads. Runs exactly once."""
raise NotImplementedError

def _is_dismissed(self) -> bool:
"""Whether the teardown has run. Guards live on this, not on a repeated
attribute lookup a typo could silently disable."""
return getattr(self, "_teardown_done", False)

def _run_teardown_once(self) -> None:
if self._is_dismissed():
return
self._teardown_done = True
self._teardown()

def done(self, result: int) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().done(result)

def closeEvent(self, event) -> None: # noqa: N802 — Qt naming
self._run_teardown_once()
super().closeEvent(event)


class NumericItem(QStandardItem):
"""A QStandardItem that compares by its Qt.UserRole int payload.

Expand Down
4 changes: 3 additions & 1 deletion PyMemoryEditor/app/application.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -539,7 +539,9 @@ def main(argv=None):
app.exec()
finally:
try:
process.close()
# `process` is only the handle the picker opened; File → Change
# Process… released that one and attached the window to another.
window.process.close()
except Exception:
pass

Expand Down
12 changes: 12 additions & 0 deletions PyMemoryEditor/app/cheat_poll_worker.py
Original file line numberDiff line numberDiff line change
Expand Up@@ -106,6 +106,11 @@ def request_write(
with QMutexLocker(self._mutex):
self._pending_writes.append((address, pytype, length, value))

def _stopping(self) -> bool:
"""Whether ``stop`` was asked for. Cheap enough to check per entry."""
with QMutexLocker(self._mutex):
return self._stop

def stop(self) -> None:
with QMutexLocker(self._mutex):
self._stop = True
Expand All@@ -126,6 +131,8 @@ def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:

failures: List[Tuple[int, type, int, str]] = []
for address, pytype, length, value in pending:
if self._stopping():
break # the handle is about to go — see _poll_once
try:
self._process.write_process_memory(address, pytype, length, value)
except Exception as exc: # noqa: BLE001
Expand DownExpand Up@@ -206,6 +213,11 @@ def _poll_once(
values_by_address = None

for address in addresses:
# Per entry, not per tick: shutdown() closes the handle right
# after stop(), and a write on a recycled one hits another
# process.
if self._stopping():
return results
frozen_value, is_frozen = freeze_by_addr[(pytype, length, address)]
if values_by_address is not None:
current = values_by_address.get(address)
Expand Down
Loading
Loading