Repository: xonsh/xonsh

Issue:
When I run a builtin command (i.e. xonfig -h, xontrib -h, etc.) the shell exits

_Originally posted by @Serrindipity in https://github.com/xonsh/xonsh/discussions/5688_

Whenever I run an invalid xonsh command (i.e. xonfig -h, xontrib -h, etc.), the shell exits. I'm pretty new to all of this, so I'm not sure if I'm doing something wrong. This persists even when running xonsh --no-rc --no-env

Xonsh is installed with brew, version 0.18.3 on MacOS.

I've tried uninstalling and re-installing as well. Not sure what's happening.

Hints from issue comments:
@anki-code: I can reproduce this.
```xsh
xonsh --no-rc
xontrib -h
# back to previous shell
```
PR with fix is very welcome!@Spitfire1900: https://github.com/xonsh/xontrib-vox/issues/12 may be caused by this issue.@Serrindipity: I'd be willing to work on this, but I'm not sure where to start. I'm pretty new here, what's the best way to start digging into the issue?@anki-code: @Serrindipity it would be great! First of all prepare the [environment in IDE](https://github.com/anki-code/xonsh-developer-toolkit/?tab=readme-ov-file#pycharm) to have interactive debugging. Then try to debug [`xontrib` alias](https://github.com/xonsh/xonsh/blob/61537db9bdd329951cd4b0a982a603915cdddaec/xonsh/xontribs.py#L406).

We had a PRs around catching exit code recently:
* https://github.com/xonsh/xonsh/pull/5594
* #5399

May be we have regress after these PRs. Try to checkout the code before/after the changes and test `xonfig -h`.@Serrindipity: It looks like when end() is called in pipelines.py, it never gets to self._return_terminal() at line 479. I think it just errors on self._end(tee_output=tee_output), the line before it.@yaxollum: @anki-code I think the issue is that xonsh uses `argparse.ArgumentParser` to implement `xonfig`, but the code that prints the help message in `ArgumentParser` calls `sys.exit()`:

https://github.com/python/cpython/blob/a67a39272c61de5202c44690d10a385c9ecaf97c/Lib/argparse.py#L1142-L1144

https://github.com/python/cpython/blob/a67a39272c61de5202c44690d10a385c9ecaf97c/Lib/argparse.py#L2656-L2659

The changes made in #5399 cause xonsh to no longer catch the `SystemExit` exception that is raised by `sys.exit()`:
```xonsh
# Before the change:
import sys
sys.exit() # nothing happens

# Now:
import sys
sys.exit() # shell exits
```

I think this is the correct behaviour in general, but for this specific case, `ArgumentParser` causes the entire shell to exit. We could probably fix this by catching `SystemExit` in the [`ArgParserAlias`](https://github.com/xonsh/xonsh/blob/31170c2dafdec1c3208b566a6359da9f1a1b9842/xonsh/cli_utils.py#L560) class.
@anki-code: @yaxollum thank you for diving into this and the explanation! Yes, we need to replace exiting to returning the exit code.

Solution patch:
diff --git a/news/fix-procproxy-sys-exit.rst b/news/fix-procproxy-sys-exit.rst
new file mode 100644
index 00000000..b1a14081
--- /dev/null
+++ b/news/fix-procproxy-sys-exit.rst
@@ -0,0 +1,23 @@
+**Added:**
+
+* <news item>
+
+**Changed:**
+
+* Now SystemExit that invoked from callable alias follows to exiting from callable alias instead of exiting the entire shell.
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* Built-in commands such as `xonfig -h` and `xontrib -h` no longer cause the shell to exit.
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/procs/proxies.py b/xonsh/procs/proxies.py
index 6796b77f..0fd29baf 100644
--- a/xonsh/procs/proxies.py
+++ b/xonsh/procs/proxies.py
@@ -792,6 +792,10 @@ class ProcProxy:
                         "stack": spec.stack,
                     },
                 )
+        except SystemExit as e:
+            # the alias function is running in the main thread, so we need to
+            # catch SystemExit to prevent the entire shell from exiting (see #5689)
+            r = e.code if isinstance(e.code, int) else int(bool(e.code))
         except Exception:
             xt.print_exception(source_msg="Exception in " + get_proc_proxy_name(self))
             r = 1


Test patch:
diff --git a/tests/test_integrations.py b/tests/test_integrations.py
index 915a82d5..8609920f 100644
--- a/tests/test_integrations.py
+++ b/tests/test_integrations.py
@@ -329,6 +329,22 @@ f
         "hello\n",
         0,
     ),
+    # test system exit in unthreadable alias (see #5689)
+    (
+        """
+from xonsh.tools import unthreadable
+
+@unthreadable
+def _f():
+    import sys
+    sys.exit(42)
+
+aliases['f'] = _f
+print(![f].returncode)
+""",
+        "42\n",
+        0,
+    ),
     # test ambiguous globs
     (
         """