Repository: python-cmd2/cmd2

Issue:
Persistent history file not saved when application is killed
When killing the Cmd2 application (e.g. by closing the terminal window), persistent history file is not saved.

By using `persistent_history_file` argument for Cmd.__init__(), and hitting `quit` command, I can see the persistent history command is updated as expected.

Environment : Ubuntu 22 on WLS2

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/python-cmd2/cmd2/pull/1321?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 98.52%. Comparing base [(`be15939`)](https://app.codecov.io/gh/python-cmd2/cmd2/commit/be1593929f046d44e0d27fcd902fdb9998ba6bb3?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2) to head [(`afa8019`)](https://app.codecov.io/gh/python-cmd2/cmd2/commit/afa8019370822b396b60e8b23fa8f3e92bf1b5ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master    #1321      +/-   ##
==========================================
- Coverage   98.56%   98.52%   -0.04%     
==========================================
  Files          22       22              
  Lines        5780     5782       +2     
==========================================
  Hits         5697     5697              
- Misses         83       85       +2     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/python-cmd2/cmd2/pull/1321?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=python-cmd2).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dac58426..378fffcd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/cmd2/cmd2.py b/cmd2/cmd2.py
index 5f95b2ab..1381f0d5 100644
--- a/cmd2/cmd2.py
+++ b/cmd2/cmd2.py
@@ -2405,13 +2405,13 @@ class Cmd(cmd.Cmd):
         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
@@ -2427,6 +2427,23 @@ class Cmd(cmd.Cmd):
             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")
@@ -5426,11 +5443,18 @@ class Cmd(cmd.Cmd):
         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()
@@ -5464,9 +5488,13 @@ class Cmd(cmd.Cmd):
         # 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
 
     ###


Test patch:
diff --git a/tests/test_cmd2.py b/tests/test_cmd2.py
index 5686e9f9..2130cbf1 100755
--- a/tests/test_cmd2.py
+++ b/tests/test_cmd2.py
@@ -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)