Skip to content

Commit

Permalink
Merge pull request #1321 from python-cmd2/handle_termination_signals
Browse files Browse the repository at this point in the history
Handle termination signals
  • Loading branch information
kmvanbrunt authored Sep 13, 2024
2 parents 16c2dbe + 3972880 commit 65cdf34
Show file tree
Hide file tree
Showing 3 changed files with 47 additions and 6 deletions.
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

0 comments on commit 65cdf34

Please sign in to comment.