PyMemoryEditor 3.2.0__py3-none-any.whl → 3.3.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_auto_refresh_dialog.py +6 -7
  3. PyMemoryEditor/app/_widgets.py +7 -7
  4. PyMemoryEditor/app/application.py +5 -6
  5. PyMemoryEditor/app/cheat_poll_worker.py +23 -17
  6. PyMemoryEditor/app/cheat_table.py +78 -38
  7. PyMemoryEditor/app/main_window.py +84 -30
  8. PyMemoryEditor/app/memory_viewer_dialog.py +5 -5
  9. PyMemoryEditor/app/open_process_dialog.py +1 -1
  10. PyMemoryEditor/app/pointer_chain_dialog.py +2 -1
  11. PyMemoryEditor/app/pointer_scan_dialog.py +85 -6
  12. PyMemoryEditor/app/results_view.py +76 -13
  13. PyMemoryEditor/app/scan_worker.py +10 -0
  14. PyMemoryEditor/app/scanner_panel.py +1 -1
  15. PyMemoryEditor/app/value_types.py +3 -3
  16. PyMemoryEditor/linux/functions.py +58 -25
  17. PyMemoryEditor/linux/libc.py +2 -3
  18. PyMemoryEditor/linux/process.py +43 -88
  19. PyMemoryEditor/macos/functions.py +150 -56
  20. PyMemoryEditor/macos/process.py +60 -102
  21. PyMemoryEditor/mcp/config.py +46 -21
  22. PyMemoryEditor/mcp/policy.py +191 -24
  23. PyMemoryEditor/mcp/procinfo.py +258 -0
  24. PyMemoryEditor/mcp/server.py +232 -25
  25. PyMemoryEditor/mcp/session.py +74 -22
  26. PyMemoryEditor/mcp/toolset.py +318 -100
  27. PyMemoryEditor/process/_guard.py +43 -0
  28. PyMemoryEditor/process/abstract.py +273 -31
  29. PyMemoryEditor/process/info.py +3 -1
  30. PyMemoryEditor/process/pointer_scan.py +134 -13
  31. PyMemoryEditor/process/region.py +10 -2
  32. PyMemoryEditor/process/scanning.py +60 -35
  33. PyMemoryEditor/util/__init__.py +1 -0
  34. PyMemoryEditor/util/convert.py +13 -3
  35. PyMemoryEditor/util/pattern.py +11 -1
  36. PyMemoryEditor/util/scan.py +53 -2
  37. PyMemoryEditor/win32/enums/memory_protections.py +1 -1
  38. PyMemoryEditor/win32/functions.py +115 -40
  39. PyMemoryEditor/win32/process.py +48 -100
  40. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/METADATA +12 -6
  41. pymemoryeditor-3.3.1.dist-info/RECORD +75 -0
  42. pymemoryeditor-3.2.0.dist-info/RECORD +0 -73
  43. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/WHEEL +0 -0
  44. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/entry_points.txt +0 -0
  45. {pymemoryeditor-3.2.0.dist-info → pymemoryeditor-3.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -8,7 +8,7 @@ Supported platforms: Windows, Linux and macOS (32-bit and 64-bit).
8
8
  """
9
9
 
10
10
  __author__ = "Jean Loui Bernard Silva de Jesus"
11
- __version__ = "3.2.0"
11
+ __version__ = "3.3.1"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -3,13 +3,12 @@
3
3
  Shared base for the read-only, auto-refreshing table dialogs (Memory Map,
4
4
  Modules, Threads).
5
5
 
6
- All three did the same dance — run a one-shot read off the UI thread, repopulate
6
+ All three do the same dance — run a one-shot read off the UI thread, repopulate
7
7
  a sortable table when it returns, poll on a timer, and tear the worker down
8
- safely on close — each with its own near-identical ``_*Worker(QThread)`` and
9
- lifecycle boilerplate. That triplicated the fiddly bits (the in-flight guard
10
- that self-throttles the poll, the detach-on-shutdown safety) where a fix to one
11
- copy wouldn't reach the others. This owns the lifecycle once; subclasses provide
12
- only what differs: how to fetch the data, and how to render it.
8
+ safely on close. This owns that lifecycle once, so the fiddly bits (the
9
+ in-flight guard that self-throttles the poll, the detach-on-shutdown safety)
10
+ live in one place; subclasses provide only what differs: how to fetch the
11
+ data, and how to render it.
13
12
 
14
13
  It also owns *when* a failure is worth interrupting the user — see
15
14
  :meth:`AutoRefreshTableDialog._handle_failed`.
@@ -182,7 +181,7 @@ class AutoRefreshTableDialog(TearsDownOnClose, QDialog):
182
181
  Process) *every* tick fails, and ``_on_data_failed`` pops a **modal**
183
182
  dialog. A modal runs a nested event loop, so the timer fires again while
184
183
  it is up and the failures stack — hundreds of focus-stealing dialogs the
185
- user could only escape by killing the app from another TTY (issue #74).
184
+ user could only escape by killing the app from another TTY.
186
185
 
187
186
  Reporting is therefore tied to the two moments where a failure changes
188
187
  what the user sees: the **first** fetch (empty window) and the one that
@@ -2,8 +2,8 @@
2
2
 
3
3
  """Small Qt widgets shared between dialogs.
4
4
 
5
- Centralises tiny helpers (numeric sort items, hex address parsing) that
6
- previously appeared duplicated across several dialog modules.
5
+ Centralises tiny helpers (numeric sort items, hex address parsing) shared by
6
+ several dialog modules.
7
7
  """
8
8
 
9
9
  import threading
@@ -46,9 +46,9 @@ def shutdown_worker_thread(worker: Optional[QThread], wait_ms: int = 2000) -> No
46
46
  cancel()
47
47
  try:
48
48
  # The four-argument static form on purpose: bare `worker.disconnect()`
49
- # raises TypeError in PySide6 (so this except swallowed it and nothing
50
- # was disconnected), and `worker.disconnect(worker)` only drops
51
- # connections whose *receiver* is the worker — ours belong to the dialog.
49
+ # raises TypeError in PySide6, and `worker.disconnect(worker)` only
50
+ # drops connections whose *receiver* is the worker — ours belong to the
51
+ # dialog.
52
52
  QObject.disconnect(worker, None, None, None)
53
53
  except (RuntimeError, TypeError):
54
54
  pass
@@ -141,8 +141,8 @@ class TearsDownOnClose:
141
141
  ``QDialog`` delivers a ``QCloseEvent`` only for a real close. The app's own
142
142
  "Close" buttons call ``accept()`` and Esc reaches ``reject()``; both land in
143
143
  ``done()``, which hides the dialog and emits ``finished`` *without* any
144
- close event — so a teardown living in ``closeEvent`` never ran, and the
145
- dialog kept polling, hidden and unreachable.
144
+ close event — so a teardown living in ``closeEvent`` would never run, and
145
+ the dialog would keep polling, hidden and unreachable.
146
146
 
147
147
  Subclasses implement :meth:`_teardown`; the mixin runs it exactly once,
148
148
  whichever way the dialog is dismissed, and must precede ``QDialog`` in the
@@ -542,8 +542,7 @@ def main(argv=None):
542
542
 
543
543
  ``argv`` defaults to ``sys.argv`` so packaging tools (which call
544
544
  ``main()`` with no arguments) keep working. Tests and embedders can pass
545
- an explicit list — previously a positional ``*args`` was accepted but
546
- ignored, which made the parameter meaningless.
545
+ an explicit list.
547
546
 
548
547
  Terminal users want Ctrl+C to kill the app, which takes a process-wide
549
548
  signal change — see :func:`main_cli`, the console-script entry point.
@@ -602,8 +601,8 @@ def main(argv=None):
602
601
  # Change Process… released that one and attached the window
603
602
  # to another.
604
603
  window.process.close()
605
- except Exception:
606
- pass
604
+ except Exception: # noqa: BLE001 — exiting anyway; keep the cause
605
+ _LOG.debug("Closing the process handle on exit failed", exc_info=True)
607
606
 
608
607
 
609
608
  def main_cli(argv=None):
@@ -611,9 +610,9 @@ def main_cli(argv=None):
611
610
  Entry point for the ``pymemoryeditor`` console script and ``python -m``.
612
611
 
613
612
  Qt's event loop blocks inside C++, so Python's default SIGINT handler only
614
- runs once the interpreter next regains control. In practice it raised
613
+ runs once the interpreter next regains control. In practice it raises
615
614
  KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override,
616
- where PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT
615
+ where PySide6 swallows it and merely prints a traceback. Hand SIGINT
617
616
  back to the OS for the duration of the run so Ctrl+C from a terminal
618
617
  terminates the app immediately.
619
618
 
@@ -56,8 +56,8 @@ class _CheatPollWorker(QThread):
56
56
  with the current ``{key: "ErrorType: message"}`` map. The signal fires only
57
57
  when that set *changes* (a freeze starts or stops failing), so the UI gets a
58
58
  persistent cue without 10 Hz spam, and the first failure of each entry is
59
- logged once. Previously the exception was swallowed silently, so a freeze
60
- that never landed looked identical to one that did.
59
+ logged once. Swallowing the exception instead would make a freeze that
60
+ never landed look identical to one that did.
61
61
  """
62
62
 
63
63
  values_ready = Signal(object) # list[tuple[int, type, int, Any]]
@@ -96,9 +96,9 @@ class _CheatPollWorker(QThread):
96
96
  ) -> None:
97
97
  """Queue a one-shot manual write to be performed on the worker thread.
98
98
 
99
- The cheat table's inline value edits used to call
100
- ``write_process_memory`` directly on the UI thread, freezing the UI when
101
- the target was slow or the page faulted. Routing them here keeps the
99
+ The cheat table's inline value edits come through here: calling
100
+ ``write_process_memory`` directly on the UI thread would freeze the UI
101
+ when the target is slow or the page faults. Routing them here keeps the
102
102
  syscall off the UI thread; a write that fails comes back via the
103
103
  ``write_failed`` signal. Latency is at most one tick (~100 ms), which is
104
104
  imperceptible for a manual edit.
@@ -115,20 +115,21 @@ class _CheatPollWorker(QThread):
115
115
  with QMutexLocker(self._mutex):
116
116
  self._stop = True
117
117
 
118
- def _drain_pending_writes(self) -> List[Tuple[int, type, int, str]]:
119
- """Perform and clear every queued manual write.
118
+ def _take_pending_writes(self) -> List[Tuple[int, type, int, Any]]:
119
+ """Detach the queued writes. The caller holds ``self._mutex``."""
120
+ pending = self._pending_writes
121
+ self._pending_writes = []
122
+ return pending
123
+
124
+ def _perform_writes(
125
+ self, pending: List[Tuple[int, type, int, Any]]
126
+ ) -> List[Tuple[int, type, int, str]]:
127
+ """Perform manual writes taken with :meth:`_take_pending_writes`.
120
128
 
121
129
  Returns one ``(address, pytype, length, message)`` tuple per write that
122
- failed, so ``run`` can surface it via ``write_failed``. Reads and clears
123
- the queue under the lock, then runs the syscalls outside it so a slow
124
- write never blocks a UI thread enqueuing the next edit.
130
+ failed, so ``run`` can surface it via ``write_failed``. Runs outside the
131
+ lock, so a slow write never blocks a UI thread enqueuing the next edit.
125
132
  """
126
- with QMutexLocker(self._mutex):
127
- if not self._pending_writes:
128
- return []
129
- pending = self._pending_writes
130
- self._pending_writes = []
131
-
132
133
  failures: List[Tuple[int, type, int, str]] = []
133
134
  for address, pytype, length, value in pending:
134
135
  if self._stopping():
@@ -146,10 +147,15 @@ class _CheatPollWorker(QThread):
146
147
 
147
148
  def run(self) -> None:
148
149
  while True:
150
+ # Snapshot and queued writes are taken together, under one lock:
151
+ # an inline edit on a frozen row publishes the new frozen value and
152
+ # queues its write back to back, and a snapshot copied before that
153
+ # pair would re-freeze the old value over the write just drained.
149
154
  with QMutexLocker(self._mutex):
150
155
  if self._stop:
151
156
  return
152
157
  snapshot = list(self._snapshot)
158
+ pending = self._take_pending_writes()
153
159
 
154
160
  # Never let an exception escape the loop body: a QThread whose
155
161
  # run() raises dies silently, and the table would then stop
@@ -158,7 +164,7 @@ class _CheatPollWorker(QThread):
158
164
  try:
159
165
  # Apply queued manual writes before reading, so a just-typed
160
166
  # value lands and is read back as the new current value this tick.
161
- for failure in self._drain_pending_writes():
167
+ for failure in self._perform_writes(pending):
162
168
  self.write_failed.emit(failure)
163
169
 
164
170
  if snapshot:
@@ -12,8 +12,8 @@ stays fresh.
12
12
  This module hosts only the Qt widget; the dataclass and the worker thread
13
13
  live in ``cheat_entry.py`` and ``cheat_poll_worker.py`` respectively. The
14
14
  ``CheatEntry`` and ``_CheatPollWorker`` names are re-exported from here for
15
- backward compatibility with code (and tests) that imported them from this
16
- module before the split.
15
+ backward compatibility with code (and tests) that import them from this
16
+ module.
17
17
  """
18
18
  import copy
19
19
  import json
@@ -95,8 +95,8 @@ class CheatTable(QWidget):
95
95
  self._poller.start()
96
96
 
97
97
  # A short cadence to push fresh entry snapshots into the worker. This
98
- # is far cheaper than the previous QTimer that did real syscalls — it
99
- # only copies a small list of tuples.
98
+ # is cheap — it does no syscalls, it only copies a small list of
99
+ # tuples.
100
100
  self._publish_timer = QTimer(self)
101
101
  self._publish_timer.setInterval(TICK_INTERVAL_MS)
102
102
  self._publish_timer.timeout.connect(self._publish_snapshot_to_worker)
@@ -221,8 +221,8 @@ class CheatTable(QWidget):
221
221
  self._table.customContextMenuRequested.connect(self._show_context_menu)
222
222
  layout.addWidget(self._table, 1)
223
223
 
224
- # Delete removes the selected rows — the standard gesture, previously
225
- # only reachable through the context menu. Scoped to the table widget
224
+ # Delete removes the selected rows — the standard gesture, alongside
225
+ # the context menu. Scoped to the table widget
226
226
  # (WidgetShortcut) so Delete still edits text while a cell is in
227
227
  # inline-edit mode (the editor child, not the table, has focus then).
228
228
  delete_shortcut = QShortcut(QKeySequence(QKeySequence.Delete), self._table)
@@ -230,27 +230,48 @@ class CheatTable(QWidget):
230
230
  delete_shortcut.activated.connect(self._on_remove_selected)
231
231
 
232
232
  def add_entry(self, entry: CheatEntry) -> None:
233
- # Every entry enters here, whichever way it was created — promoted from
234
- # a scan, from a pointer dialog, added by hand, or loaded from JSON. A
235
- # zero-width buffer reads back empty on every poll tick and can't be
236
- # spotted from the table, so the floor is enforced once, at the door,
237
- # rather than at each of those call sites. (The AOB pattern spec is the
238
- # one whose declared length is 0 — the scanner derives its real width
239
- # from the pattern.) The ceiling is the same one every other width
240
- # field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
241
- # and silently resizing its entry would alter what the user exports
242
- # next, so that is refused -- the import loop reports and skips it.
243
- length = int(entry.length)
244
- if length > MAX_ENTRY_LENGTH:
245
- raise ValueError(
246
- "entry at 0x%X has length %d, over the %d-byte maximum"
247
- % (entry.address, length, MAX_ENTRY_LENGTH)
248
- )
249
- entry.length = max(1, length)
233
+ self._add_entries([entry])
234
+
235
+ def _add_entries(self, new_entries: List[CheatEntry]) -> None:
236
+ """Merge ``new_entries`` into the table and rebuild it once.
237
+
238
+ Every entry enters here, whichever way it was created — promoted from
239
+ a scan, from a pointer dialog, added by hand, or loaded from JSON. A
240
+ zero-width buffer reads back empty on every poll tick and can't be
241
+ spotted from the table, so the floor is enforced once, at the door,
242
+ rather than at each of those call sites. (The AOB pattern spec is the
243
+ one whose declared length is 0 — the scanner derives its real width
244
+ from the pattern.) The ceiling is the same one every other width
245
+ field enforces (MAX_ENTRY_LENGTH); only a JSON import can carry more,
246
+ and silently resizing its entry would alter what the user exports
247
+ next, so that is refused -- the import loop reports and skips it.
248
+
249
+ Batched so a bulk promotion costs one address lookup per row and one
250
+ rebuild in total: one ``add_entry`` per row would do a linear
251
+ duplicate scan plus a full table rebuild each, quadratic in the
252
+ selection.
253
+
254
+ :raises ValueError: if an entry's length is over ``MAX_ENTRY_LENGTH``;
255
+ checked for every entry before any is added.
256
+ """
257
+ for entry in new_entries:
258
+ length = int(entry.length)
259
+ if length > MAX_ENTRY_LENGTH:
260
+ raise ValueError(
261
+ "entry at 0x%X has length %d, over the %d-byte maximum"
262
+ % (entry.address, length, MAX_ENTRY_LENGTH)
263
+ )
250
264
 
251
- # If the address already exists, just refresh its description/type.
265
+ # First entry per address wins, should the table hold duplicates.
266
+ by_address: Dict[int, CheatEntry] = {}
252
267
  for existing in self._entries:
253
- if existing.address == entry.address:
268
+ by_address.setdefault(existing.address, existing)
269
+
270
+ for entry in new_entries:
271
+ entry.length = max(1, int(entry.length))
272
+ # If the address already exists, just refresh its description/type.
273
+ existing = by_address.get(entry.address)
274
+ if existing is not None:
254
275
  existing.description = entry.description or existing.description
255
276
  # Re-promoting an address that is already in the table is the
256
277
  # third place a spec_label changes, and the cached value has to
@@ -260,10 +281,10 @@ class CheatTable(QWidget):
260
281
  _forget_value_read_as_another_type(existing)
261
282
  existing.spec_label = entry.spec_label
262
283
  existing.length = entry.length
263
- self._rebuild()
264
- return
284
+ continue
285
+ self._entries.append(entry)
286
+ by_address[entry.address] = entry
265
287
 
266
- self._entries.append(entry)
267
288
  self._rebuild()
268
289
 
269
290
  def add_addresses(
@@ -283,15 +304,17 @@ class CheatTable(QWidget):
283
304
  "length %d is over the %d-byte maximum for a cheat-table entry"
284
305
  % (int(length), MAX_ENTRY_LENGTH)
285
306
  )
286
- for addr in addresses:
287
- self.add_entry(
307
+ self._add_entries(
308
+ [
288
309
  CheatEntry(
289
310
  description=description,
290
311
  address=int(addr),
291
312
  spec_label=spec.label,
292
313
  length=int(length),
293
314
  )
294
- )
315
+ for addr in addresses
316
+ ]
317
+ )
295
318
 
296
319
  def entries(self) -> List[CheatEntry]:
297
320
  return list(self._entries)
@@ -325,8 +348,8 @@ class CheatTable(QWidget):
325
348
  type_label = entry.spec_label
326
349
  # Show the width whenever it belongs to the entry rather than to the
327
350
  # spec. An IDA pattern declares none (length 0) yet carries a real one
328
- # here — writes are refused against it — so hiding it left the user
329
- # told to "widen the entry" with no way to see what it holds.
351
+ # here — writes are refused against it — so hiding it would leave the
352
+ # user told to "widen the entry" with no way to see what it holds.
330
353
  if entry.spec.accepts_length_override or not entry.spec.length:
331
354
  type_label += f" · {entry.length}B"
332
355
  type_item = QTableWidgetItem(type_label)
@@ -405,14 +428,30 @@ class CheatTable(QWidget):
405
428
  # Nothing to route it to once shutdown() handed the worker over,
406
429
  # and nothing to write to while the target is known to be gone.
407
430
  if self._poller_stopped or self._paused:
431
+ # Put the cell back and say so, as the bulk edit does —
432
+ # returning quietly would leave the typed text on screen as if
433
+ # it had been written.
434
+ self._suspend_signals = True
435
+ item.setText(self._value_text_for(entry))
436
+ self._suspend_signals = False
437
+ QMessageBox.warning(
438
+ self,
439
+ "Write value",
440
+ f"0x{entry.address:X}: the target process is no longer available.",
441
+ )
408
442
  return
409
- self._poller.request_write(
410
- entry.address, entry.spec.pytype, entry.length, value
411
- )
412
443
 
413
444
  entry.last_value = value
414
445
  if entry.frozen:
415
446
  entry.frozen_value = value
447
+ # Hand the worker the new frozen value *before* queuing the
448
+ # write: its snapshot otherwise still holds the old one until
449
+ # the next publish tick, and the freeze loop would write that
450
+ # stale value straight over the edit.
451
+ self._publish_snapshot_to_worker()
452
+ self._poller.request_write(
453
+ entry.address, entry.spec.pytype, entry.length, value
454
+ )
416
455
 
417
456
  def _publish_snapshot_to_worker(self) -> None:
418
457
  """Hand the worker a fresh immutable snapshot of every entry."""
@@ -487,7 +526,8 @@ class CheatTable(QWidget):
487
526
  against it here is cheap and keeps the cue in sync: a red value cell plus
488
527
  an explanatory tooltip while a freeze keeps failing, restored to normal
489
528
  the moment the write lands again. Without this, a frozen value that the
490
- backend silently refuses to write looked identical to one that worked.
529
+ backend silently refuses to write would look identical to one that
530
+ worked.
491
531
  """
492
532
  rows_by_key: Dict[Tuple[int, type, int], int] = {}
493
533
  for row, entry in enumerate(self._entries):
@@ -873,7 +913,7 @@ class CheatTable(QWidget):
873
913
 
874
914
 
875
915
  # Ceiling for an entry's buffer width. Entries are promoted at the width of the
876
- # value scanned for, which the scanner doesn't cap, so 1024 was too tight — but
916
+ # value scanned for, which the scanner doesn't cap, so 1024 is too tight — but
877
917
  # the poll worker allocates this many bytes per entry on every 100 ms tick, so
878
918
  # an unbounded field turns one typo into a multi-gigabyte allocation the tick's
879
919
  # blanket except swallows and retries forever. A megabyte is far past any real
@@ -383,11 +383,12 @@ class MainWindow(QMainWindow):
383
383
  # The worker builds the region snapshot off the UI thread; cache it here
384
384
  # for the refine/update scans that reuse it.
385
385
  worker.snapshot_ready.connect(self._on_snapshot_ready)
386
- # Connection order matters: _cleanup_worker must clear self._worker
387
- # before _fill_initial_values runs, otherwise the busy guard in
388
- # _on_update_values rejects the auto-refresh.
389
- worker.finished.connect(self._cleanup_worker)
390
- worker.finished.connect(lambda: self._fill_initial_values(request))
386
+ # One slot, not two connections in a load-bearing order: it clears
387
+ # self._worker (via _cleanup_worker) before _fill_initial_values runs,
388
+ # otherwise the busy guard in _on_update_values rejects the
389
+ # auto-refresh — and it runs that refresh only after a scan that went
390
+ # through, not after Cancel or an error.
391
+ worker.finished.connect(lambda: self._on_first_scan_finished(request))
391
392
  self._worker = worker
392
393
  worker.start()
393
394
 
@@ -464,10 +465,25 @@ class MainWindow(QMainWindow):
464
465
  self._worker = worker
465
466
  worker.start()
466
467
 
468
+ def _on_first_scan_finished(self, request: ScanRequest) -> None:
469
+ worker = self._worker
470
+ completed = worker is not None and worker.completed()
471
+ self._cleanup_worker()
472
+ # A cancelled scan still emits finished_ok (with the partial count) and
473
+ # a failed one emits error; either way the user asked for no more
474
+ # work — otherwise Cancel would start "Updating values…", and a dead
475
+ # target would pop a second error dialog from the refresh.
476
+ if completed:
477
+ self._fill_initial_values(request)
478
+
467
479
  def _fill_initial_values(self, request: ScanRequest) -> None:
468
480
  # If the first-scan worker dropped or had zero hits, skip the refresh.
469
481
  if self._results_model.count() == 0:
470
482
  return
483
+ # The heartbeat locked the scanner while the scan ran: the target is
484
+ # gone, so a refresh could only fail.
485
+ if self._target_dead:
486
+ return
471
487
  # Don't recurse into another scan if the user has already triggered one.
472
488
  if self._worker is not None:
473
489
  return
@@ -529,7 +545,18 @@ class MainWindow(QMainWindow):
529
545
  self._status.showMessage(message)
530
546
 
531
547
  def _cleanup_worker(self) -> None:
548
+ # Runs from the worker's `finished`, and only one scan runs at a time,
549
+ # so self._worker is the worker that just finished. It is a child of
550
+ # this window: dropping the reference alone would keep every scan's
551
+ # QThread (and the address list / value map it was handed) alive until
552
+ # the window closed. `finished` is emitted as run() returns, so wait()
553
+ # comes back at once; it makes sure the thread is really over before
554
+ # deleteLater() (destroying a running QThread aborts the process).
555
+ worker = self._worker
532
556
  self._worker = None
557
+ if worker is not None:
558
+ worker.wait()
559
+ worker.deleteLater()
533
560
  # A scan that was running when the target exited finishes (with an
534
561
  # error) after the heartbeat locked the scanner; releasing the lock
535
562
  # here would hand the controls back against a dead handle.
@@ -570,7 +597,24 @@ class MainWindow(QMainWindow):
570
597
  snap = self._memory_map.snapshot()
571
598
  if snap:
572
599
  self._region_snapshot = snap
573
- self._memory_map = None
600
+ self._release_dialog("_memory_map")
601
+
602
+ def _release_dialog(self, attr: str) -> None:
603
+ """Forget a closed tool dialog and free it.
604
+
605
+ The dialogs are children of this window and are rebuilt on the next
606
+ open, so dropping the reference alone would keep every closed instance
607
+ (and the tables it held) alive for the life of the window. By the time
608
+ ``finished`` gets here the dialog's teardown has already run — it is
609
+ driven from ``done()`` — so its background worker is stopped, or
610
+ detached from it if it would not stop, and the delete can't take a
611
+ running thread down with it. deleteLater(), not delete: this runs
612
+ inside the dialog's own ``done()``.
613
+ """
614
+ dialog = getattr(self, attr, None)
615
+ setattr(self, attr, None)
616
+ if dialog is not None:
617
+ dialog.deleteLater()
574
618
 
575
619
  def _open_threads_dialog(self) -> None:
576
620
  if self._threads_dialog is None:
@@ -583,7 +627,7 @@ class MainWindow(QMainWindow):
583
627
  self._threads_dialog.activateWindow()
584
628
 
585
629
  def _on_threads_dialog_closed(self, _result: int) -> None:
586
- self._threads_dialog = None
630
+ self._release_dialog("_threads_dialog")
587
631
 
588
632
  def _open_modules_dialog(self) -> None:
589
633
  if self._modules_dialog is None:
@@ -602,7 +646,7 @@ class MainWindow(QMainWindow):
602
646
  self._modules_dialog.activateWindow()
603
647
 
604
648
  def _on_modules_dialog_closed(self, _result: int) -> None:
605
- self._modules_dialog = None
649
+ self._release_dialog("_modules_dialog")
606
650
 
607
651
  def _open_pointer_chain_dialog(self) -> None:
608
652
  if self._pointer_chain_dialog is None:
@@ -618,7 +662,7 @@ class MainWindow(QMainWindow):
618
662
  self._pointer_chain_dialog.activateWindow()
619
663
 
620
664
  def _on_pointer_chain_dialog_closed(self, _result: int) -> None:
621
- self._pointer_chain_dialog = None
665
+ self._release_dialog("_pointer_chain_dialog")
622
666
 
623
667
  def _on_module_resolve_chain(self, base_address: int) -> None:
624
668
  """Open the Pointer Chain tool with a module's base address prefilled."""
@@ -675,7 +719,7 @@ class MainWindow(QMainWindow):
675
719
  self._pointer_scan_dialog.activateWindow()
676
720
 
677
721
  def _on_pointer_scan_dialog_closed(self, _result: int) -> None:
678
- self._pointer_scan_dialog = None
722
+ self._release_dialog("_pointer_scan_dialog")
679
723
 
680
724
  def _on_pointer_scan_promote(
681
725
  self, address: int, spec_label: str, length: int
@@ -694,7 +738,7 @@ class MainWindow(QMainWindow):
694
738
  self._log_console_dialog.activateWindow()
695
739
 
696
740
  def _on_log_console_closed(self, _result: int) -> None:
697
- self._log_console_dialog = None
741
+ self._release_dialog("_log_console_dialog")
698
742
 
699
743
  def _open_hex_viewer(self, address: int) -> None:
700
744
  self._open_hex_viewer_with_size(address, 256)
@@ -875,8 +919,8 @@ class MainWindow(QMainWindow):
875
919
 
876
920
  # Tear down auxiliary dialogs that hold a reference to the old
877
921
  # process — reopening them rebuilds against the new target. The Memory
878
- # Map belongs here too: it polls on a timer, so leaving it open left it
879
- # reading the handle closed just below (issue #74).
922
+ # Map belongs here too: it polls on a timer, so leaving it open would
923
+ # leave it reading the handle closed just below.
880
924
  for dialog_attr in (
881
925
  "_memory_map",
882
926
  "_threads_dialog",
@@ -908,8 +952,8 @@ class MainWindow(QMainWindow):
908
952
  # until GC. shutdown() is idempotent and joins the worker.
909
953
  try:
910
954
  old_cheat.shutdown()
911
- except Exception:
912
- pass
955
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
956
+ _LOG.debug("Old cheat table shutdown failed", exc_info=True)
913
957
  # A worker that blew its join is detached, not stopped, so it can still
914
958
  # be inside a syscall on the old handle. Release the handle only once
915
959
  # every detached worker has finished; closing it now could hand its
@@ -963,8 +1007,8 @@ class MainWindow(QMainWindow):
963
1007
  # before the process handle goes away in `application.main`'s finally.
964
1008
  try:
965
1009
  self._cheat.shutdown()
966
- except Exception:
967
- pass
1010
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
1011
+ _LOG.debug("Cheat table shutdown failed", exc_info=True)
968
1012
 
969
1013
  self._shutdown_worker()
970
1014
 
@@ -975,8 +1019,8 @@ class MainWindow(QMainWindow):
975
1019
  """Ask before quitting with unsaved cheat entries. True = go ahead.
976
1020
 
977
1021
  The table is not persisted between runs (Export… is the way to keep
978
- it), so a stray Cmd+Q / window close used to throw away every saved
979
- address silently.
1022
+ it), so a stray Cmd+Q / window close would otherwise throw away every
1023
+ saved address silently.
980
1024
  """
981
1025
  count = self._cheat.entry_count()
982
1026
  if count == 0 or not self.confirm_close_with_entries:
@@ -1018,16 +1062,26 @@ class MainWindow(QMainWindow):
1018
1062
  return
1019
1063
  self._worker = None
1020
1064
  worker.cancel()
1021
- try:
1022
- worker.chunk_ready.disconnect()
1023
- worker.progress.disconnect()
1024
- worker.status.disconnect()
1025
- worker.error.disconnect()
1026
- worker.finished_ok.disconnect()
1027
- worker.finished.disconnect()
1028
- except (RuntimeError, TypeError):
1029
- # Already disconnected / no slots — fine, we just want them gone.
1030
- pass
1065
+ # Each signal on its own, so one that has no slots can't leave the
1066
+ # rest connected. snapshot_ready exists on the first-scan worker only;
1067
+ # a late one would cache a snapshot on a window that is going away.
1068
+ for name in (
1069
+ "chunk_ready",
1070
+ "progress",
1071
+ "status",
1072
+ "error",
1073
+ "finished_ok",
1074
+ "snapshot_ready",
1075
+ "finished",
1076
+ ):
1077
+ signal = getattr(worker, name, None)
1078
+ if signal is None:
1079
+ continue
1080
+ try:
1081
+ signal.disconnect()
1082
+ except (RuntimeError, TypeError):
1083
+ # Already disconnected / no slots — fine, we just want them gone.
1084
+ pass
1031
1085
 
1032
1086
  # Pump queued cross-thread calls so any in-flight blocking emit is
1033
1087
  # dispatched (which releases the worker even though we just
@@ -1051,7 +1105,7 @@ def _close_quietly(process) -> None:
1051
1105
  try:
1052
1106
  process.close()
1053
1107
  except Exception: # noqa: BLE001 — the target may already be gone
1054
- pass
1108
+ _LOG.debug("Closing the previous process handle failed", exc_info=True)
1055
1109
 
1056
1110
 
1057
1111
  def _safe_for_json(value) -> object:
@@ -151,9 +151,9 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
151
151
  self._timer = QTimer(self)
152
152
  self._timer.timeout.connect(self.refresh)
153
153
 
154
- # Failure bookkeeping, same shape as the auto-refresh dialogs: a dead
155
- # target used to log a warning every tick forever (~20/s at the 50 ms
156
- # floor). Keyed by (address, size, message) so moving the viewer to
154
+ # Failure bookkeeping, same shape as the auto-refresh dialogs: without
155
+ # it a dead target would log a warning every tick forever (~20/s at the
156
+ # 50 ms floor). Keyed by (address, size, message) so moving the viewer to
157
157
  # another failing range still gets logged.
158
158
  self._last_failure: Optional[tuple] = None
159
159
  self._failing_since: Optional[QElapsedTimer] = None
@@ -171,7 +171,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
171
171
  # Skip if a read is still running. The auto-refresh timer fires this
172
172
  # repeatedly, and a large length (up to 65536) on a slow target
173
173
  # (notably macOS Mach-VM) takes long enough that running it on the UI
174
- # thread froze input — so the read now runs on a worker, and this guard
174
+ # thread would freeze input — so the read runs on a worker, and this guard
175
175
  # self-throttles the cadence to the real read time instead of stacking
176
176
  # workers.
177
177
  if self._worker is not None and self._worker.isRunning():
@@ -188,7 +188,7 @@ class MemoryViewerDialog(TearsDownOnClose, QDialog):
188
188
  # travel back in the result so the rendered dump stays labelled with
189
189
  # the range actually read even if the user edits the fields mid-read.
190
190
  # Errors are returned (not raised) so the one result handler renders
191
- # them with the same message/log the synchronous path produced.
191
+ # every outcome through a single message/log path.
192
192
  try:
193
193
  data = process.read_process_memory(addr, bytes, size)
194
194
  # A backend returning a non-buffer object would make bytes(data)
@@ -288,7 +288,7 @@ class OpenProcessDialog(TearsDownOnClose, QDialog):
288
288
  selected_pid = self._selected_pid()
289
289
 
290
290
  # Restoring the selection below re-triggers Qt's scrollTo(current), which
291
- # jumped the list back to the selected row on every tick (#75). The
291
+ # would jump the list back to the selected row on every tick. The
292
292
  # rebuild is harmless on its own: Qt defers the scrollbar range update.
293
293
  scrollbar = self._table.verticalScrollBar()
294
294
  scroll_offset = scrollbar.value()