Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle termination signals #1321

Merged
merged 1 commit into from
Sep 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
## 2.5.0 (TBD)
* Breaking Change
* `cmd2` 2.5 supports Python 3.8+ (removed support for Python 3.6 and 3.7)
* Bug Fixes
* Fixed issue where persistent history file was not saved upon SIGHUP and SIGTERM signals.
* Enhancements
* Removed dependency on `attrs` and replaced with [dataclasses](https://docs.python.org/3/library/dataclasses.html)
* add `allow_clipboard` initialization parameter and attribute to disable ability to
Expand Down
40 changes: 34 additions & 6 deletions cmd2/cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -2405,13 +2405,13 @@ def get_help_topics(self) -> List[str]:
return [topic for topic in all_topics if topic not in self.hidden_commands and topic not in self.disabled_commands]

# noinspection PyUnusedLocal
def sigint_handler(self, signum: int, _: FrameType) -> None:
def sigint_handler(self, signum: int, _: Optional[FrameType]) -> None:
"""Signal handler for SIGINTs which typically come from Ctrl-C events.

If you need custom SIGINT behavior, then override this function.
If you need custom SIGINT behavior, then override this method.

:param signum: signal number
:param _: required param for signal handlers
:param _: the current stack frame or None
"""
if self._cur_pipe_proc_reader is not None:
# Pass the SIGINT to the current pipe process
Expand All @@ -2427,6 +2427,23 @@ def sigint_handler(self, signum: int, _: FrameType) -> None:
if raise_interrupt:
self._raise_keyboard_interrupt()

def termination_signal_handler(self, signum: int, _: Optional[FrameType]) -> None:
"""
Signal handler for SIGHUP and SIGTERM. Only runs on Linux and Mac.

SIGHUP - received when terminal window is closed
SIGTERM - received when this app has been requested to terminate

The basic purpose of this method is to call sys.exit() so our exit handler will run
and save the persistent history file. If you need more complex behavior like killing
threads and performing cleanup, then override this method.

:param signum: signal number
:param _: the current stack frame or None
"""
# POSIX systems add 128 to signal numbers for the exit code
sys.exit(128 + signum)

def _raise_keyboard_interrupt(self) -> None:
"""Helper function to raise a KeyboardInterrupt"""
raise KeyboardInterrupt("Got a keyboard interrupt")
Expand Down Expand Up @@ -5426,11 +5443,18 @@ def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override]
if not threading.current_thread() is threading.main_thread():
raise RuntimeError("cmdloop must be run in the main thread")

# Register a SIGINT signal handler for Ctrl+C
# Register signal handlers
import signal

original_sigint_handler = signal.getsignal(signal.SIGINT)
signal.signal(signal.SIGINT, self.sigint_handler) # type: ignore
signal.signal(signal.SIGINT, self.sigint_handler)

if not sys.platform.startswith('win'):
original_sighup_handler = signal.getsignal(signal.SIGHUP)
signal.signal(signal.SIGHUP, self.termination_signal_handler)

original_sigterm_handler = signal.getsignal(signal.SIGTERM)
signal.signal(signal.SIGTERM, self.termination_signal_handler)

# Grab terminal lock before the command line prompt has been drawn by readline
self.terminal_lock.acquire()
Expand Down Expand Up @@ -5464,9 +5488,13 @@ def cmdloop(self, intro: Optional[str] = None) -> int: # type: ignore[override]
# This will also zero the lock count in case cmdloop() is called again
self.terminal_lock.release()

# Restore the original signal handler
# Restore original signal handlers
signal.signal(signal.SIGINT, original_sigint_handler)

if not sys.platform.startswith('win'):
signal.signal(signal.SIGHUP, original_sighup_handler)
signal.signal(signal.SIGTERM, original_sigterm_handler)

return self.exit_code

###
Expand Down
11 changes: 11 additions & 0 deletions tests/test_cmd2.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,6 +1038,17 @@ def test_raise_keyboard_interrupt(base_app):
assert 'Got a keyboard interrupt' in str(excinfo.value)


@pytest.mark.skipif(sys.platform.startswith('win'), reason="SIGTERM only handeled on Linux/Mac")
def test_termination_signal_handler(base_app):
with pytest.raises(SystemExit) as excinfo:
base_app.termination_signal_handler(signal.SIGHUP, 1)
assert excinfo.value.code == signal.SIGHUP + 128

with pytest.raises(SystemExit) as excinfo:
base_app.termination_signal_handler(signal.SIGTERM, 1)
assert excinfo.value.code == signal.SIGTERM + 128


class HookFailureApp(cmd2.Cmd):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
Loading