Repository: xonsh/xonsh

Issue:
xonsh always returns zero when used as the shell in vim/neovim
## Expected Behavior
If I run a command within vim/neovim and that command fails, I should see a non-zero return code in `v:shell_error`.

## Current Behavior
If the shell for vim/neovim to use is xonsh, `v:shell_error` always contains zero.

## Steps to Reproduce
open vim/neovim
```
:set shell=bash
:!rm non-existent-file
/usr/bin/rm cannot remove 'non-existent-file': No such file or directory

shell returned 1

Press ENTER or type command to continue
:echo(v:shell_error)
1

:set shell=xonsh
:!rm non-existent-file
/usr/bin/rm cannot remove 'non-existent-file': No such file or directory

Press ENTER or type command to continue
:echo(v:shell_error)
0
```

## xonfig
+------------------+-----------------+
| xonsh            | 0.9.8           |
| Python           | 3.7.5           |
| PLY              | 3.11            |
| have readline    | True            |
| prompt toolkit   | 2.0.10          |
| shell type       | prompt_toolkit2 |
| pygments         | 2.3.1           |
| on posix         | True            |
| on linux         | True            |
| distro           | ubuntu          |
| on darwin        | False           |
| on windows       | False           |
| on cygwin        | False           |
| on msys2         | False           |
| is superuser     | False           |
| default encoding | utf-8           |
| xonsh encoding   | utf-8           |
| encoding errors  | surrogateescape |
+------------------+-----------------+

## Further Info
This works fine outside vim/neovim within a terminal running xonsh:
```
!(rm non-existent-file)
/usr/bin/rm: cannot remove 'non-existent-file': No such file or directory
CommandPipeline(stdin=<_io.BytesIO object at 0x7f056e858d10>, stdout=<_io.BytesIO object at 0x7f056e858d70>, stderr=<_io.BytesIO object at 0x7f056e858dd0>, pid=32062, returncode=1, args=['rm', 'non-existent-file'], alias=None, stdin_redirect=['<stdin>', 'r'], stdout_redirect=[9, 'wb'], stderr_redirect=[11, 'w'], timestamps=[1576055751.5550349, 1576055751.5620785], executed_cmd=['/usr/bin/rm', 'non-existent-file'], input=, output=, errors=/usr/bin/rm: cannot remove 'non-existent-file': No such file or directory)
```

It even works fine within a vim/neovim terminal when xonsh is the shell:
open vim/neovim
```
:set shell=xonsh
:terminal
!(rm non-existent-file)
/usr/bin/rm: cannot remove 'non-existent-file': No such file or directory
CommandPipeline(stdin=<_io.BytesIO object at 0x7fb79b2af410>, stdout=<_io.BytesIO object at 0x7fb79b2af470>, stderr=<_io.BytesIO object at 0x7fb79b2af4d0>, pid=31794, returncode=1, args=['rm', 'non-existent-file'], alias=None, stdin_redirect=['<stdin>', 'r'], stdout_redirect=[7, 'wb'], stderr_redirect=[9, 'w'], timestamps=[1576055686.1392198, 1576055686.1452942], executed_cmd=['/usr/bin/rm', 'non-existent-file'], input=, output=, errors=/usr/bin/rm: cannot remove 'non-existent-file': No such file or directory)
```

Possibly related to #3309

## Update
Confirmed as showing identical behaviour on OSX (description above is from Ubuntu).


Discussion:
Huh -- thanks for reporting @meatballs -- I agree that this is probably related to #3309.  I'll try to take a look soon.
If you're able to give me a clue as to where I might start, I'm happy to look into this too.
Any program executing code in the users shell and reacting depending on the return code will always get return code 0 (in my case a `Match Exec` entry in .ssh/config always matching, no matter the actual return code of the executed code).

This is also easy to reproduce with xonsh/0.9.11:
```
$ xonsh -c "echo 'hello' | grep 'world'"; _.rtn                                                                                                                                                                              
0
$ bash -c "echo 'hello' | grep 'world'"; _.rtn                                                                                                                                                                               
1
$ bash -c "exit 0"; _.rtn
0
$ bash -c "exit 1"; _.rtn
1
$ bash -c "exit 123"; _.rtn
123
$ xonsh -c "exit 0"; _.rtn

0
$ xonsh -c "exit 1"; _.rtn

0
$ xonsh -c "exit 123"; _.rtn

0
```
If you want to forward the return code, you need to use the quite verbose `import sys; sys.exit(![command])`. Maybe using the same semantics as bash (http://tldp.org/LDP/abs/html/exit-status.html) could be helpful?
#3309 was fixed by #3465 but that hasn't fixed this problem.

Resolving patch:
diff --git a/xonsh/main.py b/xonsh/main.py
--- a/xonsh/main.py
+++ b/xonsh/main.py
@@ -419,7 +419,7 @@ def main(argv=None):
     args = None
     try:
         args = premain(argv)
-        return main_xonsh(args)
+        sys.exit(main_xonsh(args))
     except Exception as err:
         _failback_to_other_shells(args, err)
 
@@ -437,6 +437,8 @@ def func_sig_ttin_ttou(n, f):
     events.on_post_init.fire()
     env = builtins.__xonsh__.env
     shell = builtins.__xonsh__.shell
+    history = builtins.__xonsh__.history
+    exit_code = 0
     try:
         if args.mode == XonshMode.interactive:
             # enter the shell
@@ -454,6 +456,8 @@ def func_sig_ttin_ttou(n, f):
         elif args.mode == XonshMode.single_command:
             # run a single command and exit
             run_code_with_cache(args.command.lstrip(), shell.execer, mode="single")
+            if history is not None and history.last_cmd_rtn is not None:
+                exit_code = history.last_cmd_rtn
         elif args.mode == XonshMode.script_from_file:
             # run a script contained in a file
             path = os.path.abspath(os.path.expanduser(args.file))
@@ -467,8 +471,7 @@ def func_sig_ttin_ttou(n, f):
                 )
             else:
                 print("xonsh: {0}: No such file or directory.".format(args.file))
-                events.on_exit.fire()
-                sys.exit(1)
+                exit_code = 1
         elif args.mode == XonshMode.script_from_stdin:
             # run a script given on stdin
             code = sys.stdin.read()
@@ -478,6 +481,7 @@ def func_sig_ttin_ttou(n, f):
     finally:
         events.on_exit.fire()
     postmain(args)
+    return exit_code
 
 
 def postmain(args=None):


Test patch:
diff --git a/tests/test_integrations.py b/tests/test_integrations.py
--- a/tests/test_integrations.py
+++ b/tests/test_integrations.py
@@ -52,7 +52,7 @@
 )
 
 
-def run_xonsh(cmd, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.STDOUT):
+def run_xonsh(cmd, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.STDOUT, single_command=False):
     env = dict(os.environ)
     env["PATH"] = PATH
     env["XONSH_DEBUG"] = "0" # was "1"
@@ -61,23 +61,31 @@ def run_xonsh(cmd, stdin=sp.PIPE, stdout=sp.PIPE, stderr=sp.STDOUT):
     env["PROMPT"] = ""
     xonsh = "xonsh.bat" if ON_WINDOWS else "xon.sh"
     xonsh = shutil.which(xonsh, path=PATH)
+    if single_command:
+        args = [xonsh, "--no-rc", "-c", cmd]
+        input = None
+    else:
+        args = [xonsh, "--no-rc"]
+        input = cmd
+
     proc = sp.Popen(
-        [xonsh, "--no-rc"],
+        args,
         env=env,
         stdin=stdin,
         stdout=stdout,
         stderr=stderr,
         universal_newlines=True,
     )
+
     try:
-        out, err = proc.communicate(input=cmd, timeout=10)
+        out, err = proc.communicate(input=input, timeout=10)
     except sp.TimeoutExpired:
         proc.kill()
         raise
     return out, err, proc.returncode
 
 
-def check_run_xonsh(cmd, fmt, exp):
+def check_run_xonsh(cmd, fmt, exp, exp_rtn=0):
     """The ``fmt`` parameter is a function
     that formats the output of cmd, can be None.
     """
@@ -87,7 +95,7 @@ def check_run_xonsh(cmd, fmt, exp):
     if callable(exp):
         exp = exp()
     assert out == exp, err
-    assert rtn == 0, err
+    assert rtn == exp_rtn, err
 
 
 #
@@ -656,3 +664,19 @@ def _echo(args):
     )
     out, _, _ = run_xonsh(script)
     assert out == exp
+
+
+# issue 3402
+@skip_if_on_windows
+@pytest.mark.parametrize(
+    "cmd, exp_rtn",
+    [
+        ("sys.exit(0)", 0),
+        ("sys.exit(100)", 100),
+        ("sh -c 'exit 0'", 0),
+        ("sh -c 'exit 1'", 1),
+    ],
+)
+def test_single_command_return_code(cmd, exp_rtn):
+    _, _, rtn = run_xonsh(cmd, single_command=True)
+    assert rtn == exp_rtn