Repository: xonsh/xonsh

Issue:
Implement pipefail and errexit
I want to know when a command other then the last command in the pipeline fails for any reason. If a command in the middle of a pipeline fails, the last command simply sees a EOF in its stdin. In many cases this will result in the last command returning success. Silently ignoring such errors can lead to all kinds of data loss, data corruption, truncation and other badness. Ideally any unhandled errors should terminate the script.

bash has pipefail:
```bash
#!/usr/bin/env bash
set -o errexit -o pipefail
echo one
false | true
echo two
```
VVV
```
one
```
Note: the above example **does not** print "two"
```xon.sh
#!/usr/bin/env xon.sh
$RAISE_SUBPROC_ERROR = True
echo one
false | true
echo two
```
VVV
```
one
two
```

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**



Discussion:
More context:
Executing an external command can fail for a million different reasons. Here are some examples:
1. The external command is not installed
2. It encounters an internal bug and [fails fast and loud](https://en.wikipedia.org/wiki/Fail-fast)
3. It receives bad input due to a bug in my script
4. An admin kills it
5. The OOM Killer kills it https://linuxwheel.com/oom-killer-explained/
6. It tries to talk to another process possibly on a different machine and that fails for any reason
7. It uses a temporary file for internal buffering and /tmp is full
8. It tries to read or write a file and that fails for any reason

A robust script can not afford to ignore the possibility that any of the external commands it executes may fail. Often the right choice of action is to fail fast because the code that follows requires that the preceding commands executed successfully.

Unix-y pipelines have an issue:
```sh
foo | bar
```
If `foo` exits successfully `bar` sees EOF. If `foo` fails `bar` also sees EOF. `bar` has no way to know whether `foo` failed or not. In many cases `bar` will exit successfully even if `foo` fails. The script has to detect that and fail. This is why you often see `set -o errexit -o pipefail` in bash.

Unfortunately `set -o errexit` is [very broken](https://mywiki.wooledge.org/BashFAQ/105) in bash which is why I'm looking for a different shell.
I also miss this feature, which is quite important in my opinion. Two use cases are

`pv input | foo` (pv = pipe viewer, it generates progress output at stderr)

and 

`foo | tee logfile`

I guess these use cases are rather common, but the lack of some pipefail equivalent makes them painful with xonsh.

Resolving patch:
diff --git a/xonsh/jobs.py b/xonsh/jobs.py
--- a/xonsh/jobs.py
+++ b/xonsh/jobs.py
@@ -39,6 +39,20 @@
 _tasks_main: collections.deque[int] = collections.deque()
 
 
+def waitpid(pid, opt):
+    """
+    Transparent wrapper on `os.waitpid` to make notes about undocumented subprocess behavior.
+    Basically ``p = subprocess.Popen()`` populates ``p.returncode`` after ``p.wait()``, ``p.poll()``
+    or ``p.communicate()`` (https://docs.python.org/3/library/os.html#os.waitpid).
+    But if you're using `os.waitpid()` BEFORE these functions you're capturing return code
+    from a signal subsystem and ``p.returncode`` will be ``0``.
+    After ``os.waitid`` call you need to set return code manually
+    ``p.returncode = -os.WTERMSIG(status)`` like in Popen.
+    See also ``xonsh.tools.describe_waitpid_status()``.
+    """
+    return os.waitpid(pid, opt)
+
+
 @contextlib.contextmanager
 def use_main_jobs():
     """Context manager that replaces a thread's task queue and job dictionary
@@ -270,7 +284,7 @@ def wait_for_active_job(last_task=None, backgrounded=False, return_error=False):
             if thread.pid is None:
                 # When the process stopped before os.waitpid it has no pid.
                 raise ChildProcessError("The process PID not found.")
-            _, wcode = os.waitpid(thread.pid, os.WUNTRACED)
+            _, wcode = waitpid(thread.pid, os.WUNTRACED)
         except ChildProcessError as e:  # No child processes
             if return_error:
                 return e
@@ -284,7 +298,7 @@ def wait_for_active_job(last_task=None, backgrounded=False, return_error=False):
         elif os.WIFSIGNALED(wcode):
             print()  # get a newline because ^C will have been printed
             thread.signal = (os.WTERMSIG(wcode), os.WCOREDUMP(wcode))
-            thread.returncode = None
+            thread.returncode = -os.WTERMSIG(wcode)  # Default Popen
         else:
             thread.returncode = os.WEXITSTATUS(wcode)
             thread.signal = None
diff --git a/xonsh/procs/specs.py b/xonsh/procs/specs.py
--- a/xonsh/procs/specs.py
+++ b/xonsh/procs/specs.py
@@ -934,25 +934,32 @@ def run_subproc(cmds, captured=False, envs=None):
         return _run_specs(specs, cmds)
 
 
-def _run_specs(specs, cmds):
+def _run_command_pipeline(specs, cmds):
     captured = specs[-1].captured
     if captured == "hiddenobject":
-        command = HiddenCommandPipeline(specs)
+        cp = HiddenCommandPipeline(specs)
     else:
-        command = CommandPipeline(specs)
-    proc = command.proc
-    background = command.spec.background
+        cp = CommandPipeline(specs)
+    proc = cp.proc
+    background = cp.spec.background
     if not all(x.is_proxy for x in specs):
         xj.add_job(
             {
                 "cmds": cmds,
-                "pids": [i.pid for i in command.procs],
+                "pids": [i.pid for i in cp.procs],
                 "obj": proc,
                 "bg": background,
-                "pipeline": command,
-                "pgrp": command.term_pgid,
+                "pipeline": cp,
+                "pgrp": cp.term_pgid,
             }
         )
+    return cp
+
+
+def _run_specs(specs, cmds):
+    cp = _run_command_pipeline(specs, cmds)
+    proc, captured, background = cp.proc, specs[-1].captured, cp.spec.background
+
     # For some reason, some programs are in a stopped state when the flow
     # reaches this point, hence a SIGCONT should be sent to `proc` to make
     # sure that the shell doesn't hang.
@@ -961,16 +968,16 @@ def _run_specs(specs, cmds):
 
     # now figure out what we should return
     if captured == "object":
-        return command  # object can be returned even if backgrounding
+        return cp  # object can be returned even if backgrounding
     elif captured == "hiddenobject":
         if not background:
-            command.end()
-        return command
+            cp.end()
+        return cp
     elif background:
         return
     elif captured == "stdout":
-        command.end()
-        return command.output
+        cp.end()
+        return cp.output
     else:
-        command.end()
+        cp.end()
         return
diff --git a/xonsh/tools.py b/xonsh/tools.py
--- a/xonsh/tools.py
+++ b/xonsh/tools.py
@@ -2841,3 +2841,17 @@ def to_repr_pretty_(inst, p, cycle):
         elif len(inst):
             p.break_()
             p.pretty(dict(inst))
+
+
+def describe_waitpid_status(status):
+    """Describes ``pid, status = os.waitpid(pid, opt)`` status."""
+    funcs = [
+        os.WIFEXITED,
+        os.WEXITSTATUS,
+        os.WIFSIGNALED,
+        os.WTERMSIG,
+        os.WIFSTOPPED,
+        os.WSTOPSIG,
+    ]
+    for f in funcs:
+        print(f.__name__, "-", f(status), "-", f.__doc__)


Test patch:
diff --git a/tests/procs/test_specs.py b/tests/procs/test_specs.py
--- a/tests/procs/test_specs.py
+++ b/tests/procs/test_specs.py
@@ -1,6 +1,7 @@
 """Tests the xonsh.procs.specs"""
 
 import itertools
+import signal
 import sys
 from subprocess import Popen
 
@@ -8,7 +9,12 @@
 
 from xonsh.procs.posix import PopenThread
 from xonsh.procs.proxies import STDOUT_DISPATCHER, ProcProxy, ProcProxyThread
-from xonsh.procs.specs import SubprocSpec, cmds_to_specs, run_subproc
+from xonsh.procs.specs import (
+    SubprocSpec,
+    _run_command_pipeline,
+    cmds_to_specs,
+    run_subproc,
+)
 from xonsh.pytest.tools import skip_if_on_windows
 from xonsh.tools import XonshError
 
@@ -134,6 +140,15 @@ def test_capture_always(
     assert exp in capfd.readouterr().out
 
 
+@skip_if_on_windows
+def test_interrupted_process_returncode(xonsh_session):
+    xonsh_session.env["RAISE_SUBPROC_ERROR"] = False
+    cmd = [["python", "-c", "import os, signal; os.kill(os.getpid(), signal.SIGINT)"]]
+    specs = cmds_to_specs(cmd, captured="stdout")
+    (p := _run_command_pipeline(specs, cmd)).end()
+    assert p.proc.returncode == -signal.SIGINT
+
+
 @skip_if_on_windows
 @pytest.mark.parametrize(
     "cmds, exp_stream_lines, exp_list_lines",