Skip to content

fix(app): handle SIGINT so the app can close from a terminal - #78

Merged
JeanExtreme002 merged 5 commits into
JeanExtreme002:mainfrom
cromachina:fix/app-sigint-handling
Aug 5, 2026
Merged

fix(app): handle SIGINT so the app can close from a terminal#78
JeanExtreme002 merged 5 commits into
JeanExtreme002:mainfrom
cromachina:fix/app-sigint-handling

Conversation

@cromachina

@cromachina cromachina commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Why is this PR necessary, what does it do?

Allows sigint to be caught from a terminal and kill the app.

Checklist (complete all items):

  • [N/A] Added tests as necessary.
  • [✅] There is no breaking change for existing features.

References:

Fixes #76

Notes:

No notes to be shared.

@github-actions github-actions Bot added app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) labels Jul 29, 2026

@JeanExtreme002 JeanExtreme002 left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Verified this locally before reviewing (PySide6 6.11.1, Python 3.11, offscreen): without the patch the app survives 3 consecutive SIGINTs and needs a SIGKILL, reproducing #76 with the identical traceback at application.py:189; with it, the app dies on the first SIGINT. Normal shutdown unaffected, 82 tests pass, flake8 clean. The abrupt kill is safe — Linux uses process_vm_readv/writev rather than a ptrace attach, each write is a single syscall, and close() is a no-op the OS does anyway.

SIG_DFL is the right call over a graceful app.quit() handler: teardown can block for seconds (shutdown_worker_thread waits up to 2000ms per worker), and during that a second Ctrl+C would do nothing.

Non-blocking. One item matters — the handler is never restored, so an in-process caller of main() (supported per its docstring) is permanently left with SIG_DFL, including when the process picker is just cancelled. With the inline suggestion, add at line 505:

    finally:
        signal.signal(signal.SIGINT, previous_sigint)

Minor: move import signal to the module top (line 8) — the local imports here are lazy only to avoid the Qt import cost. And the checklist says tests were added but the diff has none; with the restore above, signal.getsignal() before/after main() with OpenProcessDialog stubbed to Rejected is a portable test that runs on both CI OSes.

Caveat: I only tested macOS/Linux. Windows SIGINT semantics differ and the Windows CI cells never reach this code, so it's unverified there.

Title fixed for the Conventional commit title check (: not /, lowercase subject).

Comment thread PyMemoryEditor/app/application.py Outdated
Comment on lines +479 to +480
import signal
signal.signal(signal.SIGINT, signal.SIG_DFL)

@JeanExtreme002 JeanExtreme002 Aug 3, 2026

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

The one change with a real consequence, plus the missing rationale.

signal.signal() returns the previous handler — capturing it lets the finally at line 505 restore it (see review body). Without that, any in-process caller of main() is left with SIG_DFL for good.

The suggestion drops the local import signal; please add it at the module top next to import sys instead.

Suggested change
import signal
signal.signal(signal.SIGINT, signal.SIG_DFL)
# 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 eventFilter override (line 189), 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.
previous_sigint = signal.signal(signal.SIGINT, signal.SIG_DFL)

@JeanExtreme002 JeanExtreme002 changed the title fix/Handle sigint so the app can close from a terminal fix(app): handle SIGINT so the app can close from a terminal Aug 3, 2026
cromachina and others added 2 commits August 3, 2026 12:30
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.
@github-actions github-actions Bot added the tests Test changes (tests/) label Aug 4, 2026
@JeanExtreme002

Copy link
Copy Markdown
Owner

The contextmanager is a nicer fix than what I suggested — it restores on every exit path, including exceptions. Thanks. I pushed one follow-up commit to your branch (b61bd09), since it fixes something the refactor introduced. Happy to drop it if you'd rather do it yourself.

Wrapping the whole body in with made a latent problem fatal. signal.signal() now runs on the very first statement of main(), and off the main thread it raises ValueError: signal only works in main thread of the main interpreter. I checked the before/after:

  • On main, calling main() from a worker thread returns normally.
  • On 4266c78, it raises ValueError immediately.
  • A full Qt run (QApplication + widget + exec()) does work off the main thread here, so this isn't merely theoretical — though with a native platform plugin Qt may well refuse anyway, so the real-world impact is narrow.

I'd dismissed this as unlikely in my earlier review — testing the comparison showed I was wrong, so the helper now degrades to a no-op there and keeps Ctrl+C handling for the console-script path.

Also in that commit: renamed to _scoped_signal_handler to match the module's private-helper convention (_abort_if_qt_unavailable, _hex_to_rgba, _PointerCursorFilter), reflowed the comment to the file's ~80-column wrap, and added the two regression tests the checklist mentioned. Each fails without its fix — test_main_restores_sigint_handler leaves SIG_DFL behind on 8f46725, and the off-thread test raises ValueError without the guard. Full suite: 471 passed, 14 skipped; flake8 clean; Ctrl+C still kills (returncode -2).

One note on CI: the build-speed failure on the previous push was not yours. tests/memory/test_editor.py::test_search_by_float is a racy memory-scan test in code this PR doesn't touch — SHA 4aef4c2 on main failed twice and passed four times with no code change. The rest of that job's redness was Codecov rejecting a tokenless upload from a fork. Neither should block this.

@cromachina

Copy link
Copy Markdown
Contributor Author

You could make it so there is a main for library users, so they can happily spawn the app as they please, and a _main_with_sigint for invoking the app pymemoryeditor like as declared in pyproject.toml which sets up the signal handler for convenience (for people who just want to use the app but not as a library). Best of both worlds?

def _main_with_sigint(): # Target of pyproject.toml [scripts]
  with scoped_signal_handler(signal.SIGINT, signal.SIG_DFL):
    main()

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).
Per cromachina's suggestion on JeanExtreme002#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
(JeanExtreme002#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 JeanExtreme002#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.
@JeanExtreme002

Copy link
Copy Markdown
Owner

Best of both worlds indeed — that's cleaner than what I had, and it removes the problem instead of mitigating it. Implemented in 3f06a68.

Three of the things I'd been patching around simply stop existing on the library path: main() no longer disturbs the caller's SIGINT handler (so there's nothing to restore), can't raise ValueError off the main thread, and can't hit the TypeError-on-restore edge case. Those were all consequences of a process-wide change living in a function documented as an in-process entry point.

Two adjustments to your sketch:

  • Public name. A private _main_with_sigint referenced from [project.scripts] is a name that has to be importable by packaging while being marked internal, so I went with main_cli, and it forwards argv so it stays usable as a normal function.
  • Three call sites, not one. [project.scripts] was the obvious one, but python -m PyMemoryEditor goes through __main__.py and the module's own if __name__ == "__main__": block — both terminal invocations that would otherwise silently lose Ctrl+C. Also updated the stale entry-point line in the app package docstring.

I kept the scope guard's off-main-thread and unknown-handler guards. They're now cheap insurance on a path that only the console script takes, rather than load-bearing.

Verified through the real entry points, with the eventFilter from #76 installed and the event loop actually blocked: main_cli dies on SIGINT (rc=-2), main survives with its handler untouched. The tests were reframed to match the new contract — test_main_leaves_the_callers_sigint_handler_alone fails if main() ever regains the signal call, and test_main_cli_scopes_sig_dfl_to_the_run samples the handler mid-run to confirm SIG_DFL is actually active where the loop blocks. Full suite: 473 passed, 14 skipped; flake8 clean.

Thanks for pushing back on the design rather than just taking the patch — this is a better shape.

@JeanExtreme002
JeanExtreme002 merged commit b656c55 into JeanExtreme002:main Aug 5, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

app GUI application changes (PyMemoryEditor/app/) lib Library changes (PyMemoryEditor/) tests Test changes (tests/)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

App ignores keyboard interrupt from terminal

2 participants