PyMemoryEditor 3.1.0__py3-none-any.whl → 3.3.0__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 (42) hide show
  1. PyMemoryEditor/__init__.py +1 -1
  2. PyMemoryEditor/app/_widgets.py +52 -1
  3. PyMemoryEditor/app/application.py +13 -6
  4. PyMemoryEditor/app/cheat_poll_worker.py +18 -12
  5. PyMemoryEditor/app/cheat_table.py +66 -28
  6. PyMemoryEditor/app/main_window.py +92 -33
  7. PyMemoryEditor/app/pointer_scan_dialog.py +106 -28
  8. PyMemoryEditor/app/results_view.py +71 -8
  9. PyMemoryEditor/app/scan_worker.py +10 -0
  10. PyMemoryEditor/linux/functions.py +142 -13
  11. PyMemoryEditor/linux/process.py +123 -21
  12. PyMemoryEditor/macos/functions.py +456 -173
  13. PyMemoryEditor/macos/libsystem.py +15 -0
  14. PyMemoryEditor/macos/process.py +49 -17
  15. PyMemoryEditor/macos/types.py +47 -1
  16. PyMemoryEditor/mcp/__init__.py +4 -2
  17. PyMemoryEditor/mcp/config.py +47 -14
  18. PyMemoryEditor/mcp/policy.py +185 -18
  19. PyMemoryEditor/mcp/procinfo.py +258 -0
  20. PyMemoryEditor/mcp/server.py +261 -36
  21. PyMemoryEditor/mcp/session.py +283 -47
  22. PyMemoryEditor/mcp/toolset.py +455 -81
  23. PyMemoryEditor/process/_guard.py +42 -0
  24. PyMemoryEditor/process/abstract.py +82 -35
  25. PyMemoryEditor/process/info.py +3 -1
  26. PyMemoryEditor/process/pointer_scan.py +225 -26
  27. PyMemoryEditor/process/scanning.py +248 -105
  28. PyMemoryEditor/process/thread_info.py +6 -4
  29. PyMemoryEditor/util/__init__.py +1 -0
  30. PyMemoryEditor/util/convert.py +13 -3
  31. PyMemoryEditor/util/pattern.py +48 -6
  32. PyMemoryEditor/util/scan.py +63 -4
  33. PyMemoryEditor/util/scan_numpy.py +50 -26
  34. PyMemoryEditor/win32/functions.py +210 -99
  35. PyMemoryEditor/win32/process.py +46 -12
  36. PyMemoryEditor/win32/types.py +23 -16
  37. {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.3.0.dist-info}/METADATA +14 -6
  38. pymemoryeditor-3.3.0.dist-info/RECORD +75 -0
  39. pymemoryeditor-3.1.0.dist-info/RECORD +0 -73
  40. {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.3.0.dist-info}/WHEEL +0 -0
  41. {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.3.0.dist-info}/entry_points.txt +0 -0
  42. {pymemoryeditor-3.1.0.dist-info → pymemoryeditor-3.3.0.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.1.0"
11
+ __version__ = "3.3.0"
12
12
 
13
13
  import logging
14
14
  import sys
@@ -6,9 +6,10 @@ Centralises tiny helpers (numeric sort items, hex address parsing) that
6
6
  previously appeared duplicated across several dialog modules.
7
7
  """
8
8
 
9
+ import threading
9
10
  from typing import Any, Callable, Iterable, List, Optional, Tuple
10
11
 
11
- from PySide6.QtCore import QObject, Qt, QThread
12
+ from PySide6.QtCore import QCoreApplication, QObject, Qt, QThread, QTimer
12
13
  from PySide6.QtGui import QStandardItem
13
14
 
14
15
 
@@ -84,6 +85,56 @@ def _reap_detached_worker(worker: QThread) -> None:
84
85
  worker.deleteLater()
85
86
 
86
87
 
88
+ def detached_workers_running() -> bool:
89
+ """Whether any worker that outlived its owner is still running."""
90
+ return any(worker.isRunning() for worker in _DETACHED_WORKERS)
91
+
92
+
93
+ def run_after_detached_workers(callback: Callable[[], None]) -> None:
94
+ """Run ``callback`` once every currently detached worker has finished.
95
+
96
+ A detached worker can still be inside a backend syscall on the process it
97
+ was given, so releasing that process (``close()``) must wait for it: on
98
+ Windows the handle value can be recycled for an unrelated object the moment
99
+ it is closed, and a late write from the cheat poller would land there.
100
+ With no worker still running, ``callback`` runs immediately.
101
+ """
102
+ pending = [worker for worker in _DETACHED_WORKERS if worker.isRunning()]
103
+ if not pending:
104
+ callback()
105
+ return
106
+
107
+ remaining = set(pending)
108
+ done = False
109
+ # `finished` can be delivered from the worker's own thread while the
110
+ # isFinished() sweep below runs on this one: guard the bookkeeping so the
111
+ # callback (a handle close) runs exactly once.
112
+ guard = threading.Lock()
113
+ # Whichever thread notices the last worker finishing, the callback is
114
+ # posted to the GUI thread (the application object lives there), so it
115
+ # never runs concurrently with the GUI setting up the next process.
116
+ gui = QCoreApplication.instance()
117
+
118
+ def _on_finished(worker: QThread) -> None:
119
+ nonlocal done
120
+ with guard:
121
+ remaining.discard(worker)
122
+ if remaining or done:
123
+ return
124
+ done = True
125
+ if gui is None:
126
+ callback()
127
+ else:
128
+ QTimer.singleShot(0, gui, callback)
129
+
130
+ for worker in pending:
131
+ worker.finished.connect(lambda w=worker: _on_finished(w))
132
+ # Same race as detach_worker: one may have finished before its connect.
133
+ for worker in pending:
134
+ if worker.isFinished():
135
+ _on_finished(worker)
136
+
137
+
87
138
  class TearsDownOnClose:
88
139
  """Mixin that runs a dialog's teardown on *every* way out, not just close.
89
140
 
@@ -561,6 +561,7 @@ def main(argv=None):
561
561
  from PySide6.QtCore import QSettings
562
562
  from PySide6.QtWidgets import QApplication
563
563
 
564
+ from ._widgets import detached_workers_running
564
565
  from .main_window import MainWindow
565
566
  from .open_process_dialog import OpenProcessDialog
566
567
 
@@ -591,12 +592,18 @@ def main(argv=None):
591
592
  with _scoped_excepthook(_qt_excepthook):
592
593
  app.exec()
593
594
  finally:
594
- try:
595
- # `process` is only the handle the picker opened; File → Change
596
- # Process… released that one and attached the window to another.
597
- window.process.close()
598
- except Exception:
599
- pass
595
+ # A worker that blew its join on close is detached, not stopped, and
596
+ # may still be inside a syscall on this handle; closing it now could
597
+ # hand its value to an unrelated object mid-write. The process is
598
+ # exiting, so leave it to the OS, which releases every handle then.
599
+ if not detached_workers_running():
600
+ try:
601
+ # `process` is only the handle the picker opened; File →
602
+ # Change Process… released that one and attached the window
603
+ # to another.
604
+ window.process.close()
605
+ except Exception: # noqa: BLE001 — exiting anyway; keep the cause
606
+ _LOG.debug("Closing the process handle on exit failed", exc_info=True)
600
607
 
601
608
 
602
609
  def main_cli(argv=None):
@@ -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:
@@ -230,27 +230,47 @@ 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 did a linear duplicate
251
+ scan plus a full table rebuild each, quadratic in the selection.
252
+
253
+ :raises ValueError: if an entry's length is over ``MAX_ENTRY_LENGTH``;
254
+ checked for every entry before any is added.
255
+ """
256
+ for entry in new_entries:
257
+ length = int(entry.length)
258
+ if length > MAX_ENTRY_LENGTH:
259
+ raise ValueError(
260
+ "entry at 0x%X has length %d, over the %d-byte maximum"
261
+ % (entry.address, length, MAX_ENTRY_LENGTH)
262
+ )
250
263
 
251
- # If the address already exists, just refresh its description/type.
264
+ # First entry per address, as the linear scan used to find.
265
+ by_address: Dict[int, CheatEntry] = {}
252
266
  for existing in self._entries:
253
- if existing.address == entry.address:
267
+ by_address.setdefault(existing.address, existing)
268
+
269
+ for entry in new_entries:
270
+ entry.length = max(1, int(entry.length))
271
+ # If the address already exists, just refresh its description/type.
272
+ existing = by_address.get(entry.address)
273
+ if existing is not None:
254
274
  existing.description = entry.description or existing.description
255
275
  # Re-promoting an address that is already in the table is the
256
276
  # third place a spec_label changes, and the cached value has to
@@ -260,10 +280,10 @@ class CheatTable(QWidget):
260
280
  _forget_value_read_as_another_type(existing)
261
281
  existing.spec_label = entry.spec_label
262
282
  existing.length = entry.length
263
- self._rebuild()
264
- return
283
+ continue
284
+ self._entries.append(entry)
285
+ by_address[entry.address] = entry
265
286
 
266
- self._entries.append(entry)
267
287
  self._rebuild()
268
288
 
269
289
  def add_addresses(
@@ -283,15 +303,17 @@ class CheatTable(QWidget):
283
303
  "length %d is over the %d-byte maximum for a cheat-table entry"
284
304
  % (int(length), MAX_ENTRY_LENGTH)
285
305
  )
286
- for addr in addresses:
287
- self.add_entry(
306
+ self._add_entries(
307
+ [
288
308
  CheatEntry(
289
309
  description=description,
290
310
  address=int(addr),
291
311
  spec_label=spec.label,
292
312
  length=int(length),
293
313
  )
294
- )
314
+ for addr in addresses
315
+ ]
316
+ )
295
317
 
296
318
  def entries(self) -> List[CheatEntry]:
297
319
  return list(self._entries)
@@ -405,14 +427,30 @@ class CheatTable(QWidget):
405
427
  # Nothing to route it to once shutdown() handed the worker over,
406
428
  # and nothing to write to while the target is known to be gone.
407
429
  if self._poller_stopped or self._paused:
430
+ # Put the cell back and say so, as the bulk edit does —
431
+ # returning quietly left the typed text on screen as if it had
432
+ # been written.
433
+ self._suspend_signals = True
434
+ item.setText(self._value_text_for(entry))
435
+ self._suspend_signals = False
436
+ QMessageBox.warning(
437
+ self,
438
+ "Write value",
439
+ f"0x{entry.address:X}: the target process is no longer available.",
440
+ )
408
441
  return
409
- self._poller.request_write(
410
- entry.address, entry.spec.pytype, entry.length, value
411
- )
412
442
 
413
443
  entry.last_value = value
414
444
  if entry.frozen:
415
445
  entry.frozen_value = value
446
+ # Hand the worker the new frozen value *before* queuing the
447
+ # write: its snapshot otherwise still holds the old one until
448
+ # the next publish tick, and the freeze loop would write that
449
+ # stale value straight over the edit.
450
+ self._publish_snapshot_to_worker()
451
+ self._poller.request_write(
452
+ entry.address, entry.spec.pytype, entry.length, value
453
+ )
416
454
 
417
455
  def _publish_snapshot_to_worker(self) -> None:
418
456
  """Hand the worker a fresh immutable snapshot of every entry."""
@@ -46,7 +46,7 @@ from PySide6.QtWidgets import (
46
46
  from PyMemoryEditor import AbstractProcess, __version__
47
47
 
48
48
  from ._icon import app_icon
49
- from ._widgets import detach_worker
49
+ from ._widgets import detach_worker, run_after_detached_workers
50
50
  from .application import DEFAULT_THEME_ID, THEMES, apply_theme
51
51
  from .cheat_entry import CheatEntry
52
52
  from .cheat_table import CheatTable
@@ -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 — Cancel started "Updating values…", and a dead target popped
475
+ # 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 kept every scan's QThread
551
+ # (and the address list / value map it was handed) alive until the
552
+ # 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 kept every closed instance (and
607
+ 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)
@@ -908,15 +952,13 @@ 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
913
- # Accepted window: a worker that blew its join is detached, not
914
- # stopped, so it can still be inside a syscall here. The cheat poller
915
- # writes, which is why it checks the stop flag per entry.
916
- try:
917
- old_process.close()
918
- except Exception:
919
- pass
955
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
956
+ _LOG.debug("Old cheat table shutdown failed", exc_info=True)
957
+ # A worker that blew its join is detached, not stopped, so it can still
958
+ # be inside a syscall on the old handle. Release the handle only once
959
+ # every detached worker has finished; closing it now could hand its
960
+ # value to an unrelated object while the cheat poller is mid-write.
961
+ run_after_detached_workers(lambda p=old_process: _close_quietly(p))
920
962
  old_index = self._right_splitter.indexOf(old_cheat)
921
963
  self._cheat = CheatTable(self._process)
922
964
  self._cheat.pointer_scan_for_address.connect(self._open_pointer_scan_dialog)
@@ -965,8 +1007,8 @@ class MainWindow(QMainWindow):
965
1007
  # before the process handle goes away in `application.main`'s finally.
966
1008
  try:
967
1009
  self._cheat.shutdown()
968
- except Exception:
969
- pass
1010
+ except Exception: # noqa: BLE001 — teardown must go on; keep the cause
1011
+ _LOG.debug("Cheat table shutdown failed", exc_info=True)
970
1012
 
971
1013
  self._shutdown_worker()
972
1014
 
@@ -1020,16 +1062,26 @@ class MainWindow(QMainWindow):
1020
1062
  return
1021
1063
  self._worker = None
1022
1064
  worker.cancel()
1023
- try:
1024
- worker.chunk_ready.disconnect()
1025
- worker.progress.disconnect()
1026
- worker.status.disconnect()
1027
- worker.error.disconnect()
1028
- worker.finished_ok.disconnect()
1029
- worker.finished.disconnect()
1030
- except (RuntimeError, TypeError):
1031
- # Already disconnected / no slots — fine, we just want them gone.
1032
- 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
1033
1085
 
1034
1086
  # Pump queued cross-thread calls so any in-flight blocking emit is
1035
1087
  # dispatched (which releases the worker even though we just
@@ -1049,6 +1101,13 @@ class MainWindow(QMainWindow):
1049
1101
  worker.deleteLater()
1050
1102
 
1051
1103
 
1104
+ def _close_quietly(process) -> None:
1105
+ try:
1106
+ process.close()
1107
+ except Exception: # noqa: BLE001 — the target may already be gone
1108
+ _LOG.debug("Closing the previous process handle failed", exc_info=True)
1109
+
1110
+
1052
1111
  def _safe_for_json(value) -> object:
1053
1112
  if value is None or isinstance(value, (str, int, float, bool)):
1054
1113
  return value