fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002
, '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

fix(app): report an exited target once instead of spamming error dialogs - #82

Merged
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam
Aug 18, 2026
Merged

fix(app): report an exited target once instead of spamming error dialogs#82
JeanExtreme002 merged 15 commits into
mainfrom
fix/74-process-exit-dialog-spam

Conversation

@JeanExtreme002

Copy link
Copy Markdown
Owner

Closes#74.

The bug

Attaching to a process that has already exited — or watching the current target exit with a tool window open — filled the screen with error dialogs, one per poll tick, until the app had to be killed from another terminal.

Reproduced headlessly before touching anything: 332 stacked modals in 6 seconds.

The auto-refresh dialogs (Memory Map, Modules, Threads) poll every 300–1000 ms and reported every failed poll with a QMessageBox. A modal runs a nested event loop, so the timer kept firing underneath it and each failure stacked another dialog on top of the last. File → Change Process… fed the same loop from the other side: it closed the old handle while the Memory Map and any open hex viewer were still polling through it.

The fix

A failure is reported only when it changes what the user sees:

  • the first fetch fails — the window is empty, there is nothing to look at but the error;
  • the poll gives up after 3 s of continuous failure — the table is frozen on stale data from then on.

Never twice for the same error. Everything in between heals quietly — and that silence is load-bearing, not tidiness: reporting every failure re-opens the spam, because a target failing on alternating ticks stacks a fresh modal per failure (249 modals plus a RecursionError in a repro of that case).

On giving up, the poll stops and the status line says auto-refresh stopped, including after a filter keystroke rebuilds that line. Changing process now tears down every dialog that held the old handle.

Teardown gaps this surfaced

The fix leaned on guarantees that turned out not to hold, so these came with it:

  • QDialog.done() delivers no QCloseEvent. The teardown living in closeEvent never ran for the dialogs' own Close button (accept()) or Esc (reject()) — every worker-bearing dialog kept polling, hidden and unreachable, leaking a thread per open/close cycle. A TearsDownOnClose mixin now runs the teardown exactly once, whichever way a dialog is dismissed.
  • CheatTable.shutdown() joined its poller with a bounded wait and ignored the result, so a wedged poller kept reading while the handle it read through was closed and the widget owning its thread was deleted.
  • Read-after-close of the process handle. A worker that blows its join is parked, not stopped, and could still be inside a read when the handle was released (a recycled HANDLE on Windows, a deallocated Mach port on macOS). Releasing a handle now waits for those workers, and they all live in one registry instead of two, so none is invisible to that wait.
  • application.main released the handle the picker opened rather than the one the window ended up attached to, leaking the current target's handle after a process switch.

Also documents that get_threads reports an exited process differently per platform (empty on Linux/Windows, OSError on macOS), unlike get_memory_regions and get_modules, which raise everywhere.

Deliberate trade-offs

  • A failure that heals within 3 s is now silent. That is the mechanism that prevents the spam; the table keeps updating on its good ticks.
  • When a worker never finishes, its handle is leaked rather than closed under a live read — a leaked handle is benign, a recycled one is not.
  • Clicking Open Process can take up to 1 s longer in the worst case, because the picker now joins its enumeration worker instead of skipping teardown entirely.

Verification

  • New tests: tests/app/test_auto_refresh_dialog.py (reporting policy, give-up, grace window, flapping target, dismissal via Close/Esc/close()) and tests/app/test_detached_worker_close.py (deferred and blocking handle release). tests/app100 passed.
  • Ad-hoc checks against a real MainWindow: wedged cheat poller, wedged scan worker, handle released at exit, hex-viewer lifecycle, Esc dispatched from Qt's C++ side.
  • End-to-end on Linux in a container (the reporter's platform) with a real process killed under an open Memory Map: 1 modal, polling stopped, note preserved after filtering.
  • flake8 clean, mypy clean, docs build clean.

The 15 failures elsewhere in the suite are pre-existing macOS-only mach_vm_read_overwrite failures in the core scan/memory tests — reproduced on a clean checkout with these changes stashed, and macOS is not in CI.

Selecting a process that has already exited (or watching one exit with a
tool window open) filled the screen with error dialogs until the app had
to be killed from another terminal.
The auto-refresh dialogs poll every 300-1000 ms and reported *every*
failed poll with a modal. A modal runs a nested event loop, so the timer
kept firing underneath it and the dialogs stacked - 332 of them in six
seconds in a headless repro. `File -> Change Process...` fed the same
loop from the other side: it closed the old handle while the Memory Map
and the hex viewers were still polling through it.
A failure is now reported only when it changes what the user sees: the
first fetch (the window is empty) or the one that makes the poll give up
after 3 s of continuous failure (the table is frozen from then on), and
never twice for the same error. Failures in between heal quietly, which
is what keeps a target failing on alternating ticks from stacking a modal
per failure. On giving up, the poll stops and the status line says so -
including after a filter keystroke rebuilds it. Changing process now
tears down every dialog that held the old handle.
Fixing that surfaced neighbouring teardown gaps that made the guarantee
hollow:
* `QDialog.done()` delivers no `QCloseEvent`, so the teardown that lived
in `closeEvent` never ran for the dialogs' own Close button (`accept()`)
or Esc (`reject()`). Every worker-bearing dialog kept polling, hidden
and unreachable, and leaked a thread per open/close cycle. A
`TearsDownOnClose` mixin now runs the teardown exactly once, whichever
way a dialog is dismissed.
* `CheatTable.shutdown()` joined its poller with a bounded wait and
ignored the result, so a wedged poller kept reading while the handle it
read through was closed and the widget owning its thread was deleted.
* A detached worker (one that blew its join and was parked rather than
stopped) could still be inside a read when the process handle was
released - a recycled HANDLE on Windows, a deallocated Mach port on
macOS. Releasing a handle now waits for those workers, and they all
live in one registry instead of two, so nothing is invisible to that
wait.
* `application.main` released the handle the picker opened rather than
the one the window ended up attached to, leaking the current target's
handle after a process switch.
Also documents that `get_threads` reports an exited process differently
per platform (empty on Linux/Windows, `OSError` on macOS), unlike
`get_memory_regions` and `get_modules`.
Closes#74
@github-actionsgithub-actionsBot added docs Documentation changes (docs/) app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/) labels Aug 18, 2026
@JeanExtreme002
JeanExtreme002 requested a lite review from CopilotAugust 18, 2026 05:09

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes the app’s error-dialog spam when the target process exits (or its handle is closed during process switching) by making auto-refreshing dialogs report failures only when it meaningfully changes what the user sees, and by hardening dialog/worker teardown so background QThreads don’t keep polling (or outlive their owners) in unsafe ways.

Changes:

  • Add an auto-refresh base dialog with a “report once + grace window + give-up” policy to prevent stacked modal dialogs.
  • Introduce shared detached-worker handling and a TearsDownOnClose mixin so teardown runs exactly once regardless of dismissal path (close/accept/reject).
  • Defer process-handle release until detached workers are finished (or bounded-wait at app exit), and close all worker-bearing dialogs/viewers on process switch.

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Show a summary per file
FileDescription
tests/app/test_detached_worker_close.pyNew tests covering detached-worker close deferral and bounded waiting behavior.
tests/app/test_auto_refresh_dialog.pyNew regression tests for “report once”, grace window, give-up behavior, and teardown via Close/Esc/close().
PyMemoryEditor/process/abstract.pyDocument platform differences for get_threads() on exited processes.
PyMemoryEditor/app/threads_dialog.pyAdd _on_polling_stopped() status message when auto-refresh gives up.
PyMemoryEditor/app/pointer_scan_dialog.pySwitch to TearsDownOnClose and move cleanup into _teardown().
PyMemoryEditor/app/open_process_dialog.pySwitch to TearsDownOnClose and unify worker shutdown via shutdown_worker_thread().
PyMemoryEditor/app/modules_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/memory_viewer_dialog.pySwitch to TearsDownOnClose and move timer/worker shutdown into _teardown().
PyMemoryEditor/app/memory_map_dialog.pyPreserve “auto-refresh stopped” in status line and add _on_polling_stopped().
PyMemoryEditor/app/main_window.pyClose all worker-bearing dialogs/viewers on process switch; defer old-handle close via detached-worker gate; move scan-worker detaching into shared registry.
PyMemoryEditor/app/log_console_dialog.pySwitch to TearsDownOnClose so logging handler teardown happens on accept/reject too.
PyMemoryEditor/app/cheat_table.pyMake shutdown idempotent; route poller shutdown through shutdown_worker_thread; guard UI-to-worker calls after shutdown.
PyMemoryEditor/app/application.pyOn exit, bounded-wait for detached workers then close the current window process handle (not the original picker handle).
PyMemoryEditor/app/_widgets.pyAdd shared detached-worker registry helpers and the TearsDownOnClose mixin.
PyMemoryEditor/app/_auto_refresh_dialog.pyImplement failure-grace and one-time reporting policy; stop polling after persistent failure; integrate TearsDownOnClose.
docs/app.mdDocument new “single notice + auto-refresh stopped” behavior when the target exits.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment threadPyMemoryEditor/app/_widgets.py
…etries
Review follow-ups on the dialog teardown work.
`_on_worker_finished` cleared `self._worker` and deleted it without
checking which worker had actually finished. `finished` is queued, so it
can arrive after the next tick already started a replacement: a worker
leaves `run()` (`isRunning()` goes False, so the in-flight guard lets the
next fetch through) and its signal lands a moment later, retiring the
*running* replacement instead. That drops the reference the teardown
needs and schedules a live QThread for deletion, which aborts the
process. Both the auto-refresh dialogs and the hex viewer (whose interval
goes down to 50 ms, so the two overlap easily) now retire the worker the
signal came from.
An explicit `refresh()` against a still-dead target reported nothing
until the grace window expired, because the window already had data from
before. The flag that fixes it is bound to the fetch `refresh()` starts,
so it answers that request and cannot leak into an unrelated failure
later.
The Threads window sat at "0 thread(s)" against a dead target on Linux
and Windows, where `get_threads` reports an exited process as an empty
sequence rather than raising — never reporting, never giving up, while
the Memory Map and Modules windows beside it said the process was gone.
A live process always has at least one thread, so an empty enumeration is
now treated as the target being gone.
Also: nothing may start a fetch on a dismissed dialog (the teardown latch
is one-way, so such a worker could never be joined again), and the
process picker joins its enumeration worker briefly instead of for a
second — that join runs on the click that dismisses it, waiting on a
process list nobody will read.
The process picker and the pointer scan kept the shape that was fixed in
the polling dialogs: retire whatever `self._worker` currently points at,
regardless of which worker the queued `finished` came from.
The picker is genuinely exposed — it re-enumerates every 3 s, and a
worker leaves `run()` before its signal is delivered, so the in-flight
guard can let a replacement start in that window and the late signal then
deletes the running one.
The pointer scan can't hit it today (the Scan button stays disabled until
this very slot re-enables it, so no scan can be replaced mid-flight), but
it now matches: re-enabling the UI or dropping the worker on behalf of a
superseded scan would be wrong the moment that precondition changes.
CI hit two failures that were both in the new tests, not in the app.
`tests/app/test_auto_refresh_dialog.py` left the Qt process in a state
where a *later* test segfaulted inside pytest-qt's `qtbot.wait` — enough
to take an xdist worker down with "node down: Not properly terminated",
after which the run hung until the 6 h job timeout. Reproducible
deterministically with
pytest tests/app/test_auto_refresh_dialog.py tests/app/test_app_smoke.py
The dialogs were parentless, so each one was owned by its Python wrapper
and destroyed the instant the test's reference went away, taking its
worker children with it while the event queue could still hold events for
them. They now hang off a module-scoped parent widget, so Qt owns them and
nothing is freed underneath a queued event. They are still closed per
test — that is what exercises the teardown.
`test_the_close_waits_for_a_detached_worker` also failed on Windows with
"Internal C++ object (_WedgedWorker) already deleted": once the worker
finishes, the detach machinery reaps it, so asserting on `isRunning()`
afterwards touches a deleted object. The worker now sets a plain Python
flag when its `run()` completes, and the assertions use that.
Review follow-up. The Hex Viewer is the one polling window that never
gave up: against a dead target its read fails on every tick and each one
logged a warning — up to 20 a second at the 50 ms interval floor — so the
Log Console filled with the same line forever. That is the spam of issue
#74 wearing a different hat, reported through the logger instead of a
modal.
It now logs the first tick of a failure streak rather than every one, and
stops polling after the same grace window the other dialogs use. Stopping
goes through the auto-refresh button, so the UI can't claim to be
refreshing while it isn't, and re-enabling it hands the target a fresh
grace window.
Also guards the pending-worker snapshot in
`call_when_detached_workers_finish` against a worker reaped between the
listing and the check, matching `wait_for_detached_workers` and the loop
just below it — the same list with the same hazard, and an escape there
would abort a process switch mid-teardown.
…ssage
Review follow-ups on the previous commit.
Deduping the log line by message alone went quiet about a *different*
range that started failing after the user moved the viewer — the point is
to stop repeating the same line every tick, not to hide a new problem. The
key is now (address, size, message).
Also notes in `_FAILURE_GRACE_MS` that the Hex Viewer shares the budget
(its interval goes down to 50 ms, so a tick count would have been an order
of magnitude off), and documents the viewer's behaviour alongside the
other windows in docs/app.md.
@github-actionsgithub-actionsBot removed the docs Documentation changes (docs/) label Aug 18, 2026
Scope trim after re-reading the whole change. Three pieces went in during
review rounds rather than from evidence about issue #74, and each cost
more than it bought.
* The deferred process-handle release. Two helpers, an app-exit hook and a
test module existed to keep a handle open until every detached worker
had finished. What they protected against is a read whose result is
already discarded: those fetches are read-only and their signals are
disconnected before the handle goes. `_change_process` closes inline
again, with the residual window written down where it happens. The
cheat poller still goes through `shutdown_worker_thread`, which is not
cosmetic — deleting a widget that owns a running QThread aborts the
process.
* `_report_next_failure`. It answered an explicit refresh right away
instead of at the give-up, which only differs when the user re-opens an
already-open window from the menu. A fourth piece of state in a small
state machine, for a case where the status line already shows the
previous failure.
* The stale-`finished` guard in the pointer scan, which the scan button's
own disabled state already makes unreachable.
`application.main` keeps closing `window.process` rather than the handle
the picker opened — that one was a real bug, not hardening.
The detach rationale still pointed at the handle-holding mechanism that
the trim removed. The shared registry earns its place on its own: one
place to look for threads that outlived their owner.
`worker.disconnect()` — the bare, no-argument form — raises TypeError in
PySide6 ("not enough arguments"), and the `except (RuntimeError,
TypeError): pass` around it swallowed that. Nothing was ever
disconnected: a slot still fired after the call returned. Every "a late
emit can't touch a now-closing dialog" claim in this app rested on it,
including the ones this branch added.
`QObject.disconnect(worker, None, None, None)` is the form that works.
`worker.disconnect(worker)` is not an alternative: it only drops
connections whose *receiver* is the worker, and every slot here belongs
to the owning dialog.
Two consequences of the same root cause come with it:
* `_handle_ready` / `_handle_failed` now refuse to act on a dismissed
dialog, the way `refresh` / `_start_fetch` already did. Disconnecting
cannot unqueue a delivery that was already posted, and during
`MainWindow.closeEvent` that modal would be a nested event loop in the
middle of teardown.
* The Threads window waits for two consecutive empty enumerations before
calling the target dead. An empty snapshot is legal-but-rare on a live
process (`Thread32First` can come up dry), and the first fetch is
reported the moment it fails — so a single blip announced the death of
a process that was very much alive.
Also corrects the `_change_process` comment about detached workers: the
exposure is the concurrent read itself, not the discarded result, and
that window is accepted rather than eliminated.
`detach_worker` connects the reaper to `finished` after the caller has
checked `isRunning()`. A worker that finishes inside that window has
already emitted, so the reaper never runs and a dead worker stays parked
in the registry for the life of the process. It now reaps on the spot
when the thread turns out to be finished.
Also gives the mixin an `_is_dismissed()` helper instead of repeating
`getattr(self, "_teardown_done", False)` at four call sites: a typo in
one copy would silently disable that guard, and the guards are what keep
a dismissed dialog from fetching or reporting.
Several of these blocks restated the code or told the story of how the
bug was found. Kept the notes that would stop a future edit from
reintroducing something — why the four-argument disconnect, why the
clear() after closing hex viewers, why the snapshot is cleared last —
and cut the rest.
`MainWindow.closeEvent` ran `_shutdown_worker()` — which pumps the event
loop for up to two seconds while it waits on the scan worker — before
closing the auxiliary dialogs. The dialogs are still live during that
pump, so a poll landing there reaches `_handle_failed` and pops a modal
in the middle of teardown. The heartbeat was already stopped first for
exactly this reason; the dialogs are the same hazard and were missed.
Reproduced with Modules open past its grace window and a wedged scan
worker: one modal during the pump before, none after.
Three smaller ones from the same review:
* The cheat poller checks the stop flag per entry rather than once per
tick. It is the worker that *writes*, and a write landing on a handle
closed behind it is far worse than a stale read — a recycled HANDLE on
Windows sends it into an unrelated process. Blowing the join now takes
a single syscall hanging for a second, not a large table.
* `detach_worker` sweeps finished workers out of the registry. Qt emits
`finished` before `isFinished()` flips, so the check added for that
race could miss it and leave a dead worker parked for good.
* `MemoryViewerDialog._on_read_result` and `PointerScanDialog._on_error`
refuse to act on a dismissed dialog, like the auto-refresh handlers
already did. Disconnecting cannot unqueue a delivery already posted,
and `_on_error` pops a modal.
Same pass as before, on what the last two commits added.
The registry sweep was justified by Qt emitting `finished` before
`isFinished()` flips. Measured on this build: 200 out of 200 samples had
`isFinished()` already true inside the handler, so the claim doesn't
hold and the sweep guarded nothing reproducible. The fallback that does
have a demonstrated case — a worker that finished before the reaper was
connected — stays.
@JeanExtreme002
JeanExtreme002 merged commit 7ebe4b4 into mainAug 18, 2026
13 checks passed
@github-actions
github-actionsBot deleted the fix/74-process-exit-dialog-spam branch August 18, 2026 22:55
@JeanExtreme002JeanExtreme002 mentioned this pull request Aug 28, 2026
2 tasks
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

appGUI application changes (PyMemoryEditor/app/)libLibrary changes (PyMemoryEditor/)testsTest changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Process closed error dialog spam

2 participants

@JeanExtreme002