From 8f46725769b0ad8892e5bb10358a09ca0f85b632 Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:13:02 -0400 Subject: [PATCH 1/5] Handle sigint so the app can close from a terminal --- PyMemoryEditor/app/application.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index 663ae45..f96e944 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -476,6 +476,9 @@ def main(argv=None): from ._icon import app_icon + import signal + signal.signal(signal.SIGINT, signal.SIG_DFL) + app = QApplication.instance() or QApplication(argv) app.setApplicationName("PyMemoryEditor") app.setApplicationDisplayName("PyMemoryEditor App") From 4266c7872f41f00a3d6314318a22b432b7a64cba Mon Sep 17 00:00:00 2001 From: cromachina <82557197+cromachina@users.noreply.github.com> Date: Mon, 3 Aug 2026 12:30:55 -0400 Subject: [PATCH 2/5] Add restoring previous SIGINT handler after the app exits --- PyMemoryEditor/app/application.py | 82 +++++++++++++++++++------------ 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index f96e944..f129599 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -6,6 +6,8 @@ working on Windows, Linux and macOS. """ import sys +import signal +import contextlib from dataclasses import dataclass from PyMemoryEditor import __version__ @@ -451,6 +453,18 @@ def apply_dark_theme(app) -> None: """ +@contextlib.contextmanager +def scoped_signal_handler(signalnum, handler): + """ + Temporarily change a signal handler in the scope of a with block. + """ + previous_handler = signal.signal(signalnum, handler) + try: + yield + finally: + signal.signal(signalnum, previous_handler) + + def main(argv=None): """ Entry point for the ``pymemoryeditor`` console script. @@ -466,47 +480,51 @@ def main(argv=None): if len(argv) > 1 and argv[1].strip() in ["--version", "-v"]: return print(__version__) - _abort_if_qt_unavailable() - - from PySide6.QtCore import QSettings - from PySide6.QtWidgets import QApplication + # Qt's event loop blocks inside C++, so Python's default SIGINT handler only + # runs once the interpreter next regains control. In practice it raised + # KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override, where + # PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT back + # to the OS so Ctrl+C from a terminal terminates the app immediately, and + # keep the previous handler so in-process callers are not left with SIG_DFL. + with scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): + _abort_if_qt_unavailable() - from .main_window import MainWindow - from .open_process_dialog import OpenProcessDialog + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication - from ._icon import app_icon + from .main_window import MainWindow + from .open_process_dialog import OpenProcessDialog - import signal - signal.signal(signal.SIGINT, signal.SIG_DFL) + from ._icon import app_icon - app = QApplication.instance() or QApplication(argv) - app.setApplicationName("PyMemoryEditor") - app.setApplicationDisplayName("PyMemoryEditor App") - # OrganizationName is required for QSettings() to resolve a stable path - # on every platform. - app.setOrganizationName("PyMemoryEditor") - app.setWindowIcon(app_icon()) + app = QApplication.instance() or QApplication(argv) + app.setApplicationName("PyMemoryEditor") + app.setApplicationDisplayName("PyMemoryEditor App") + # OrganizationName is required for QSettings() to resolve a stable path + # on every platform. + app.setOrganizationName("PyMemoryEditor") + app.setWindowIcon(app_icon()) - saved_theme = str(QSettings().value("theme", DEFAULT_THEME_ID)) - apply_theme(app, saved_theme) + saved_theme = str(QSettings().value("theme", DEFAULT_THEME_ID)) + apply_theme(app, saved_theme) - picker = OpenProcessDialog() - if picker.exec() != picker.DialogCode.Accepted: - return + picker = OpenProcessDialog() + if picker.exec() != picker.DialogCode.Accepted: + return - process = picker.process - if process is None: - return + process = picker.process + if process is None: + return - window = MainWindow(process) - window.show() - try: - app.exec() - finally: + window = MainWindow(process) + window.show() try: - process.close() - except Exception: - pass + app.exec() + finally: + try: + process.close() + except Exception: + pass if __name__ == "__main__": From b61bd092a06e56f23886a516b67a2581d30a7bd6 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Tue, 4 Aug 2026 11:34:53 -0300 Subject: [PATCH 3/5] fix(app): make scoped signal handler thread-safe and cover it with tests The SIGINT scope guard now wraps the whole body of main(), so signal.signal() runs on the very first statement. Off the main thread that raises ValueError, which turned a working in-process call into a hard crash: main() is documented as a supported entry point for embedders and tests. Degrade to a no-op there instead, keeping Ctrl+C handling for the console-script path. Also rename the helper to _scoped_signal_handler to match the module's convention for private helpers (_abort_if_qt_unavailable, _hex_to_rgba, _PointerCursorFilter) and reflow the comment to the file's ~80 column wrap. Adds the two regression tests the checklist promised. Each fails without its fix: test_main_restores_sigint_handler leaves SIG_DFL behind on the first commit, and the off-thread test raises ValueError without the guard above. --- PyMemoryEditor/app/application.py | 23 +++++++---- tests/app/test_app_smoke.py | 64 +++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index f129599..09c3da3 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -454,11 +454,19 @@ def apply_dark_theme(app) -> None: @contextlib.contextmanager -def scoped_signal_handler(signalnum, handler): +def _scoped_signal_handler(signalnum, handler): """ Temporarily change a signal handler in the scope of a with block. + + Becomes a no-op off the main thread, where `signal.signal` raises + ValueError: an embedder driving the app from a worker thread shouldn't be + hard-crashed just because the Ctrl+C handler can't be installed there. """ - previous_handler = signal.signal(signalnum, handler) + try: + previous_handler = signal.signal(signalnum, handler) + except ValueError: + yield + return try: yield finally: @@ -482,11 +490,12 @@ def main(argv=None): # Qt's event loop blocks inside C++, so Python's default SIGINT handler only # runs once the interpreter next regains control. In practice it raised - # KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override, where - # PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT back - # to the OS so Ctrl+C from a terminal terminates the app immediately, and - # keep the previous handler so in-process callers are not left with SIG_DFL. - with scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): + # KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter + # override, where PySide6 swallows it and merely prints a traceback (#76). + # Hand SIGINT back to the OS so Ctrl+C from a terminal terminates the app + # immediately, and restore the previous handler on the way out so + # in-process callers are not left with SIG_DFL. + with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): _abort_if_qt_unavailable() from PySide6.QtCore import QSettings diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index d8b6053..007e065 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -53,6 +53,70 @@ def test_version_flag_prints_and_exits(capsys): assert result is None +def test_main_restores_sigint_handler(monkeypatch): + """ + ``main()`` hands SIGINT to the OS so Ctrl+C can kill the app (#76), but it + must put the caller's handler back on the way out — ``main()`` is a + supported in-process entry point, so leaving SIG_DFL behind would silently + break an embedder's own shutdown handling. + """ + import signal + + from PyMemoryEditor.app import application, open_process_dialog + + class _RejectedDialog: + """Picker stub that cancels, so main() returns before building a window.""" + + class DialogCode: + Accepted = 1 + + def exec(self): + return 0 # anything != Accepted + + process = None + + monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _RejectedDialog) + + def _sentinel(signum, frame): # pragma: no cover - installed, never raised + pass + + original = signal.signal(signal.SIGINT, _sentinel) + try: + assert application.main(["pymemoryeditor"]) is None + assert signal.getsignal(signal.SIGINT) is _sentinel + finally: + signal.signal(signal.SIGINT, original) + + +def test_scoped_signal_handler_is_a_noop_off_the_main_thread(): + """ + ``signal.signal`` only works on the main thread. The helper must degrade to + a no-op there instead of raising, so an embedder that drives the app from a + worker thread keeps working. + """ + import signal + import threading + + from PyMemoryEditor.app.application import _scoped_signal_handler + + outcome = {} + + def worker(): + try: + with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): + outcome["body_ran"] = True + outcome["error"] = None + except BaseException as exc: # noqa: BLE001 - report, don't swallow + outcome["error"] = exc + + thread = threading.Thread(target=worker) + thread.start() + thread.join(timeout=10) + + assert outcome.get("body_ran") is True + assert outcome.get("error") is None + + def test_app_modules_import_cleanly(): """Every app submodule should import without side effects beyond Qt setup.""" # Order matches the dependency graph: leaves first, container last. From 6d398190a016869f0a548d6d48c3ca75520a5c86 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Tue, 4 Aug 2026 13:21:11 -0300 Subject: [PATCH 4/5] fix(app): don't crash restoring an unknown SIGINT handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signal.signal reports None as the previous handler when "an unknown handler is in effect" — one installed outside Python, which is plausible when the app is embedded in a host that set SIGINT up in C before the signal module initialized. Handing that None back raises TypeError from the scope guard's finally, turning a run that otherwise succeeded into a crash on the way out. Skip the restore in that case: leaving SIG_DFL in place is closer to the host's intent than raising. The consequence is confirmed (restoring None raises), but the trigger needs an embedding host, so this is edge-case insurance rather than a fix for an observed failure. Also notes that the ValueError guard above catches the platform's "invalid signal number" case too, which the single SIGINT call site can't hit. The new test fails without the guard (TypeError from signal.py). --- PyMemoryEditor/app/application.py | 13 ++++++++++++- tests/app/test_app_smoke.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index 09c3da3..2489484 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -465,12 +465,23 @@ def _scoped_signal_handler(signalnum, handler): try: previous_handler = signal.signal(signalnum, handler) except ValueError: + # Off the main thread. (`signal.signal` also raises ValueError for a + # signal number the platform rejects, but the only call site passes + # SIGINT, which every supported platform has.) yield return try: yield finally: - signal.signal(signalnum, previous_handler) + # `signal.signal` reports None when "an unknown handler is in effect", + # i.e. one installed outside Python — plausible when the app is + # embedded in a host that set SIGINT up in C before the signal module + # initialized. Passing that None back raises TypeError, which would + # blow up on the way out of a run that otherwise succeeded, so leave + # the handler alone instead: SIG_DFL is closer to the host's intent + # than a crash. + if previous_handler is not None: + signal.signal(signalnum, previous_handler) def main(argv=None): diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 007e065..5ef3010 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -117,6 +117,36 @@ def worker(): assert outcome.get("error") is None +def test_scoped_signal_handler_tolerates_unknown_previous_handler(monkeypatch): + """ + ``signal.signal`` reports ``None`` as the previous handler when "an unknown + handler is in effect" — one installed outside Python, which is what an + embedding host may have done before the signal module initialized. Handing + that ``None`` back to ``signal.signal`` raises TypeError, so the guard has + to skip the restore rather than crash on the way out of a good run. + """ + import signal + + from PyMemoryEditor.app.application import _scoped_signal_handler + + real_signal = signal.signal + original = signal.getsignal(signal.SIGINT) + + def fake_signal(signum, handler): + """Report None on the way in, like a C-installed handler would.""" + real_signal(signum, handler) + return None if handler is signal.SIG_DFL else original + + monkeypatch.setattr(signal, "signal", fake_signal) + try: + with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): + pass + finally: + monkeypatch.undo() + if original is not None: + signal.signal(signal.SIGINT, original) + + def test_app_modules_import_cleanly(): """Every app submodule should import without side effects beyond Qt setup.""" # Order matches the dependency graph: leaves first, container last. From 3f06a687823e49372ede5d3e0567df5d83c92a11 Mon Sep 17 00:00:00 2001 From: JeanExtreme002 Date: Tue, 4 Aug 2026 15:24:12 -0300 Subject: [PATCH 5/5] refactor(app): move Ctrl+C handling out of main() into main_cli() Per cromachina's suggestion on #78: keep main() free of process-wide side effects and put the SIGINT change in a thin wrapper that only the console script and `python -m` invoke. Handing SIGINT to the OS is what makes Ctrl+C kill the blocked Qt event loop (#76), but it is a process-wide change, and main() is documented as an in-process entry point for embedders and tests. Scoping it to main_cli() means library callers can no longer have their SIGINT handling disturbed at all, rather than having it disturbed and then restored. The scope guard's off-main-thread and unknown-handler guards stay: main_cli() can still be called from odd contexts, and they now cost nothing on the library path. Updates every entry point that was pointing at main(): [project.scripts], __main__.py, the __name__ block, and the app package docstring. Verified through the real entry points with the eventFilter from #76 installed: main_cli dies on SIGINT (rc=-2), main survives untouched. The main-leaves-handler-alone test fails if main() regains the signal call. --- PyMemoryEditor/__main__.py | 4 +- PyMemoryEditor/app/__init__.py | 4 +- PyMemoryEditor/app/application.py | 93 ++++++++++++++++++------------- pyproject.toml | 5 +- tests/app/test_app_smoke.py | 73 +++++++++++++++++++----- 5 files changed, 121 insertions(+), 58 deletions(-) diff --git a/PyMemoryEditor/__main__.py b/PyMemoryEditor/__main__.py index bdcf06c..912ae30 100644 --- a/PyMemoryEditor/__main__.py +++ b/PyMemoryEditor/__main__.py @@ -1,4 +1,4 @@ -from PyMemoryEditor.app.application import main +from PyMemoryEditor.app.application import main_cli if __name__ == "__main__": - main() + main_cli() diff --git a/PyMemoryEditor/app/__init__.py b/PyMemoryEditor/app/__init__.py index 23c7032..e311bd9 100644 --- a/PyMemoryEditor/app/__init__.py +++ b/PyMemoryEditor/app/__init__.py @@ -5,5 +5,7 @@ A Cheat-Engine-inspired memory editor built on PySide6 (Qt for Python). Cross-platform: works on Windows, Linux and macOS. -Entry point: PyMemoryEditor.app.application:main +Entry points: PyMemoryEditor.app.application:main_cli for the console script +(adds Ctrl+C handling), :main to run the app in-process without touching the +caller's signal handlers. """ diff --git a/PyMemoryEditor/app/application.py b/PyMemoryEditor/app/application.py index 2489484..8841d4b 100644 --- a/PyMemoryEditor/app/application.py +++ b/PyMemoryEditor/app/application.py @@ -486,12 +486,17 @@ def _scoped_signal_handler(signalnum, handler): def main(argv=None): """ - Entry point for the ``pymemoryeditor`` console script. + Run the app. Safe to call in-process: it installs no signal handler. ``argv`` defaults to ``sys.argv`` so packaging tools (which call ``main()`` with no arguments) keep working. Tests and embedders can pass an explicit list — previously a positional ``*args`` was accepted but ignored, which made the parameter meaningless. + + Terminal users want Ctrl+C to kill the app, which takes a process-wide + signal change — see :func:`main_cli`, the console-script entry point. + Library callers get this function untouched so embedding the app can't + disturb the host's own SIGINT handling. """ if argv is None: argv = sys.argv @@ -499,53 +504,63 @@ def main(argv=None): if len(argv) > 1 and argv[1].strip() in ["--version", "-v"]: return print(__version__) - # Qt's event loop blocks inside C++, so Python's default SIGINT handler only - # runs once the interpreter next regains control. In practice it raised - # KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter - # override, where PySide6 swallows it and merely prints a traceback (#76). - # Hand SIGINT back to the OS so Ctrl+C from a terminal terminates the app - # immediately, and restore the previous handler on the way out so - # in-process callers are not left with SIG_DFL. - with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): - _abort_if_qt_unavailable() + _abort_if_qt_unavailable() - from PySide6.QtCore import QSettings - from PySide6.QtWidgets import QApplication + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication - from .main_window import MainWindow - from .open_process_dialog import OpenProcessDialog + from .main_window import MainWindow + from .open_process_dialog import OpenProcessDialog - from ._icon import app_icon + from ._icon import app_icon - app = QApplication.instance() or QApplication(argv) - app.setApplicationName("PyMemoryEditor") - app.setApplicationDisplayName("PyMemoryEditor App") - # OrganizationName is required for QSettings() to resolve a stable path - # on every platform. - app.setOrganizationName("PyMemoryEditor") - app.setWindowIcon(app_icon()) + app = QApplication.instance() or QApplication(argv) + app.setApplicationName("PyMemoryEditor") + app.setApplicationDisplayName("PyMemoryEditor App") + # OrganizationName is required for QSettings() to resolve a stable path + # on every platform. + app.setOrganizationName("PyMemoryEditor") + app.setWindowIcon(app_icon()) - saved_theme = str(QSettings().value("theme", DEFAULT_THEME_ID)) - apply_theme(app, saved_theme) + saved_theme = str(QSettings().value("theme", DEFAULT_THEME_ID)) + apply_theme(app, saved_theme) - picker = OpenProcessDialog() - if picker.exec() != picker.DialogCode.Accepted: - return + picker = OpenProcessDialog() + if picker.exec() != picker.DialogCode.Accepted: + return - process = picker.process - if process is None: - return + process = picker.process + if process is None: + return - window = MainWindow(process) - window.show() + window = MainWindow(process) + window.show() + try: + app.exec() + finally: try: - app.exec() - finally: - try: - process.close() - except Exception: - pass + process.close() + except Exception: + pass + + +def main_cli(argv=None): + """ + Entry point for the ``pymemoryeditor`` console script and ``python -m``. + + Qt's event loop blocks inside C++, so Python's default SIGINT handler only + runs once the interpreter next regains control. In practice it raised + KeyboardInterrupt inside our own _PointerCursorFilter.eventFilter override, + where PySide6 swallows it and merely prints a traceback (#76). Hand SIGINT + back to the OS for the duration of the run so Ctrl+C from a terminal + terminates the app immediately. + + Kept separate from :func:`main` so that process-wide change only happens + when the app *is* the process, never when it is embedded in someone else's. + """ + with _scoped_signal_handler(signal.SIGINT, signal.SIG_DFL): + return main(argv) if __name__ == "__main__": - main() + main_cli() diff --git a/pyproject.toml b/pyproject.toml index 413fb80..4a360f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,7 +91,10 @@ dev = [ ] [project.scripts] -pymemoryeditor = "PyMemoryEditor.app.application:main" +# main_cli wraps main() with the SIGINT scope guard that makes Ctrl+C kill the +# app from a terminal (#76). Library callers should use main() directly, which +# installs no signal handler. +pymemoryeditor = "PyMemoryEditor.app.application:main_cli" [project.urls] Homepage = "https://github.com/JeanExtreme002/PyMemoryEditor" diff --git a/tests/app/test_app_smoke.py b/tests/app/test_app_smoke.py index 5ef3010..9e48b1a 100644 --- a/tests/app/test_app_smoke.py +++ b/tests/app/test_app_smoke.py @@ -15,6 +15,9 @@ version flag). 3. With PySide6 available, the ``MainWindow`` and ``CheatTable`` widgets can be constructed against a self-PID ``OpenProcess`` and torn down cleanly. + 4. Ctrl+C handling stays where it belongs: ``main_cli()`` scopes SIG_DFL to + the run so a terminal can kill the blocked Qt event loop, while ``main()`` + leaves the process-wide handler untouched for in-process callers. Skipped when ``PySide6`` isn't installed (the runtime dependency is opt-in via the ``app`` extra). @@ -53,37 +56,77 @@ def test_version_flag_prints_and_exits(capsys): assert result is None -def test_main_restores_sigint_handler(monkeypatch): +def _stub_cancelled_picker(monkeypatch): + """Make the process picker cancel, so main() returns before building a window.""" + from PyMemoryEditor.app import open_process_dialog + + class _RejectedDialog: + class DialogCode: + Accepted = 1 + + def exec(self): + return 0 # anything != Accepted + + process = None + + monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _RejectedDialog) + + +def _sentinel_handler(signum, frame): # pragma: no cover - installed, never raised + pass + + +def test_main_leaves_the_callers_sigint_handler_alone(monkeypatch): + """ + ``main()`` is a supported in-process entry point, so it must not touch the + process-wide SIGINT handler: an embedder's own Ctrl+C handling has to + survive running the app. The terminal-facing behaviour lives in + ``main_cli()`` instead. """ - ``main()`` hands SIGINT to the OS so Ctrl+C can kill the app (#76), but it - must put the caller's handler back on the way out — ``main()`` is a - supported in-process entry point, so leaving SIG_DFL behind would silently - break an embedder's own shutdown handling. + import signal + + from PyMemoryEditor.app import application + + _stub_cancelled_picker(monkeypatch) + + original = signal.signal(signal.SIGINT, _sentinel_handler) + try: + assert application.main(["pymemoryeditor"]) is None + assert signal.getsignal(signal.SIGINT) is _sentinel_handler + finally: + signal.signal(signal.SIGINT, original) + + +def test_main_cli_scopes_sig_dfl_to_the_run(monkeypatch): + """ + ``main_cli()`` is what the console script and ``python -m`` invoke. It hands + SIGINT to the OS so Ctrl+C kills the blocked Qt event loop (#76), then puts + the previous handler back so the change doesn't outlive the run. """ import signal from PyMemoryEditor.app import application, open_process_dialog - class _RejectedDialog: - """Picker stub that cancels, so main() returns before building a window.""" + seen = {} + class _ProbingDialog: class DialogCode: Accepted = 1 def exec(self): - return 0 # anything != Accepted + # Sampled mid-run: this is where the Qt event loop would block. + seen["inside"] = signal.getsignal(signal.SIGINT) + return 0 process = None - monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _RejectedDialog) - - def _sentinel(signum, frame): # pragma: no cover - installed, never raised - pass + monkeypatch.setattr(open_process_dialog, "OpenProcessDialog", _ProbingDialog) - original = signal.signal(signal.SIGINT, _sentinel) + original = signal.signal(signal.SIGINT, _sentinel_handler) try: - assert application.main(["pymemoryeditor"]) is None - assert signal.getsignal(signal.SIGINT) is _sentinel + assert application.main_cli(["pymemoryeditor"]) is None + assert seen["inside"] is signal.SIG_DFL + assert signal.getsignal(signal.SIGINT) is _sentinel_handler finally: signal.signal(signal.SIGINT, original)