fix(app): handle SIGINT so the app can close from a terminal - #78
Conversation
There was a problem hiding this comment.
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).
| import signal | ||
| signal.signal(signal.SIGINT, signal.SIG_DFL) |
There was a problem hiding this comment.
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.
| 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) |
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.
|
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 ( Wrapping the whole body in
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 One note on CI: the |
|
You could make it so there is a 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.
|
Best of both worlds indeed — that's cleaner than what I had, and it removes the problem instead of mitigating it. Implemented in Three of the things I'd been patching around simply stop existing on the library path: Two adjustments to your sketch:
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 Thanks for pushing back on the design rather than just taking the patch — this is a better shape. |
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):
References:
Fixes #76
Notes:
No notes to be shared.