Repository: amoffat/sh

Issue:
"Bad" signals can't be ignored with _ok_code
https://github.com/amoffat/sh/blame/11ea5c852c108b1dc98bc415c6ae5a9419b5f67e/sh.py#L1739

It seems like it's currently impossible to use `_ok_code` to prevent an exception being raised for "bad" signals. The issue was introduced by this commit back in 2016: https://github.com/amoffat/sh/commit/e98004cb15c0e3fd154af087449e64921f68caeb

```
>>> exit_code = -9
>>> ok_codes = [-9]
>>> SIGNALS_THAT_SHOULD_THROW_EXCEPTION = [9]
>>> success = exit_code in ok_codes
>>> bad_sig = -exit_code in SIGNALS_THAT_SHOULD_THROW_EXCEPTION
>>> not success or bad_sig
True
>>> exit_code not in ok_codes and (exit_code > 0 or -exit_code in
...         SIGNALS_THAT_SHOULD_THROW_EXCEPTION)
False
```


Discussion:
It's not clear to me what you are actually attempting to do. What is your use case? It looks like you want a process to succeed even though it was SIGKILL'ed:


```python
import signal
import sh

sh.sleep(999, _ok_code=[signal.SIGKILL])
```

`SIGNALS_THAT_SHOULD_THROW_EXCEPTION` is not meant for general consumption and cannot be altered.
Ah, sorry, I should have specified. You're correct: I want no exception to be raised when I send SIGKILL (or SIGTERM, or whatever) to a specific process. My actual use case:

- start `pavumeter` in the background to monitor audio levels for a stream
- start `parec` and `ffmpeg` in the foreground to record audio from that stream
- when done recording, kill `pavumeter` and proceed with the rest of the script (without forcing the user to manually close `pavumeter`)

I'm not suggesting modifying `SIGNALS_THAT_SHOULD_THROW_EXCEPTION`: that should stay as it is. I would just like the `-9` (or `-signal.SIGKILL`) in `_ok_code` to be honored, which seemed to be possible until that commit in 2016 (accidentally?) removed the ability to do so.

There's maybe a conversation to be had on how to make signal allowlisting more intuitive, since the only mention of them just being negative exit codes is [in the Architecture document](https://sh.readthedocs.io/en/latest/sections/architecture.html#exit-code-processing), but not allowing them at all feels more confusing to me.
> `SIGNALS_THAT_SHOULD_THROW_EXCEPTION` is not meant for general consumption and cannot be altered.

Just to further clarify, the code snippet in the first post was just me using the Python REPL to create a minimal reproduction of the issue, showing that the pre- and post-simplification conditions for the `if` in `get_exc_exit_code_would_raise` are not equivalent.

The "simplified" condition returns `True`, and causes an exception to be raised even if `-9` is one of the allowlisted exit codes:

```
>>> not success or bad_sig
True
```

Whereas the pre-[https://github.com/amoffat/sh/commit/e98004cb15c0e3fd154af087449e64921f68caeb](https://github.com/amoffat/sh/commit/e98004cb15c0e3fd154af087449e64921f68caeb) condition returns `False`, allowing me to ignore `-9` if I so choose:

```
>>> exit_code not in ok_codes and (exit_code > 0 or -exit_code in
...         SIGNALS_THAT_SHOULD_THROW_EXCEPTION)
False
```
Thanks for sleuthing @iamjackg. It indeed looks like a regression that nobody has noticed in 7 years. I'm kind of surprised there was no test case for this. It sounds like the fix is to revert back to the old logic (but make it easier to read), add a test, and add some documentation. If you agree, would you be willing to take a crack at it?
I'd love to, not sure when I'll have time, so if you get to this first by all means go for it.
May I kindly follow up on this thread? It's annoying to see all SIGTERM exceptions when sending SIGTERM are expected behaviors. Setting env `RUNNING_TESTS=1` in production to workaround it is not a good idea.



Patch:
diff --git a/docs/source/sections/exit_codes.rst b/docs/source/sections/exit_codes.rst
index 17119025..edca84bc 100644
--- a/docs/source/sections/exit_codes.rst
+++ b/docs/source/sections/exit_codes.rst
@@ -49,6 +49,11 @@ exception raised in this situation is :ref:`signal_exc`, which subclasses
     except sh.SignalException_SIGKILL:
         print("killed")
 
+This behavior could be blocked by appending the negative value of the signal to
+:ref:`ok_code`. All signals that raises :ref:`signal_exc` are ``[SIGABRT, 
+SIGBUS, SIGFPE, SIGILL, SIGINT, SIGKILL, SIGPIPE, SIGQUIT, SIGSEGV, SIGTERM, 
+SIGTERM]``.
+
 .. note::
 
     You can catch :ref:`signal_exc` by using either a number or a signal name.
diff --git a/docs/source/sections/special_arguments.rst b/docs/source/sections/special_arguments.rst
index 8f745f39..8cb3d0fa 100644
--- a/docs/source/sections/special_arguments.rst
+++ b/docs/source/sections/special_arguments.rst
@@ -210,6 +210,20 @@ programs use exit codes other than 0 to indicate success.
     import sh
     sh.weird_program(_ok_code=[0,3,5])
 
+If the process is killed by a signal, a :ref:`signal_exc` is raised by
+default. This behavior could be blocked by appending a negative number to
+:ref:`ok_code` that represents the signal.
+
+.. code-block:: python
+
+    import sh
+    # the process won't raise SignalException if SIGINT, SIGKILL, or SIGTERM
+    # are sent to kill the process
+    p = sh.sleep(3, _bg=True, _ok_code=[0, -2, -9, -15])
+
+    # No exception will be raised here
+    p.kill()
+
 .. seealso:: :ref:`exit_codes`
 
 .. _new_session:
diff --git a/sh.py b/sh.py
index 8f8f3c6b..c507b379 100644
--- a/sh.py
+++ b/sh.py
@@ -2,6 +2,7 @@
 https://sh.readthedocs.io/en/latest/
 https://github.com/amoffat/sh
 """
+
 # ===============================================================================
 # Copyright (C) 2011-2023 by Andrew Moffat
 #
@@ -1730,7 +1731,10 @@ def fn(chunk):
 def get_exc_exit_code_would_raise(exit_code, ok_codes, sigpipe_ok):
     exc = None
     success = exit_code in ok_codes
-    bad_sig = -exit_code in SIGNALS_THAT_SHOULD_THROW_EXCEPTION
+    signals_that_should_throw_exception = [
+        sig for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION if -sig not in ok_codes
+    ]
+    bad_sig = -exit_code in signals_that_should_throw_exception
 
     # if this is a piped command, SIGPIPE must be ignored by us and not raise an
     # exception, since it's perfectly normal for the consumer of a process's


Test patch:
diff --git a/tests/sh_test.py b/tests/sh_test.py
index 04198069..64b70181 100644
--- a/tests/sh_test.py
+++ b/tests/sh_test.py
@@ -33,6 +33,20 @@
 tempdir = Path(tempfile.gettempdir()).resolve()
 IS_MACOS = platform.system() in ("AIX", "Darwin")
 
+SIGNALS_THAT_SHOULD_THROW_EXCEPTION = [
+    signal.SIGABRT,
+    signal.SIGBUS,
+    signal.SIGFPE,
+    signal.SIGILL,
+    signal.SIGINT,
+    signal.SIGKILL,
+    signal.SIGPIPE,
+    signal.SIGQUIT,
+    signal.SIGSEGV,
+    signal.SIGTERM,
+    signal.SIGSYS,
+]
+
 
 def hash(a: str):
     h = md5(a.encode("utf8") + RAND_BYTES)
@@ -87,6 +101,7 @@ def append_module_path(env, m):
 append_module_path(baked_env, sh)
 python = system_python.bake(_env=baked_env, _return_cmd=True)
 pythons = python.bake(_return_cmd=False)
+python_bg = system_python.bake(_env=baked_env, _bg=True)
 
 
 def requires_progs(*progs):
@@ -3137,6 +3152,61 @@ def test_unchecked_pipeline_failure(self):
             ErrorReturnCode_2, python, middleman_normal_pipe, consumer.name
         )
 
+    def test_bad_sig_raise_exception(self):
+        # test all bad signal are correctly raised
+        py = create_tmp_test(
+            """
+import time
+import sys
+
+time.sleep(2)
+sys.exit(1)
+"""
+        )
+        for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION:
+            if sig == signal.SIGPIPE:
+                continue
+            sig_exception_name = f"SignalException_{sig}"
+            sig_exception = getattr(sh, sig_exception_name)
+            try:
+                p = python_bg(py.name)
+                time.sleep(0.5)
+                p.signal(sig)
+                p.wait()
+            except sig_exception:
+                pass
+            else:
+                self.fail(f"{sig_exception_name} not raised")
+
+    def test_ok_code_ignores_bad_sig_exception(self):
+        # Test if I have [-sig] in _ok_code, the exception won't be raised
+        py = create_tmp_test(
+            """
+import time
+import sys
+
+time.sleep(2)
+sys.exit(1)
+"""
+        )
+        for sig in SIGNALS_THAT_SHOULD_THROW_EXCEPTION:
+            if sig == signal.SIGPIPE:
+                continue
+            sig_exception_name = f"SignalException_{sig}"
+            sig_exception = getattr(sh, sig_exception_name)
+            python_bg_no_sig_exception = python_bg.bake(_ok_code=[-sig])
+            try:
+                p = python_bg_no_sig_exception(py.name)
+                time.sleep(0.5)
+                p.signal(sig)
+                p.wait()
+            except sig_exception:
+                self.fail(
+                    f"{sig_exception_name} should not be raised setting _ok_code."
+                )
+            else:
+                self.assertEqual(p.exit_code, -sig)
+
 
 class MockTests(BaseTests):
     def test_patch_command_cls(self):