Repository: xonsh/xonsh

Issue:
Ctrl+D force quit not working on prompt-toolkit
Using Ctrl+D to force quit xonsh when there is a background job like `sleep 500 &` running is broken on `prompt-toolkit`.

## To reproduce

1. Run `sleep 500 &`
2. Pressing Ctrl+D gives a message like:
```
xonsh: there is an unfinished job
-----
[1]+ running: sleep 500 & & (38398)
-----
Type "exit" or press "ctrl-d" again to force quit.
```
3. Afterwards, repeatedly pressing Ctrl+D gives the same message every time (the shell does not exit)

Full output:

```
yaxollum@fedora ~ main $ sleep 500 &
[1]+ running: sleep 500 & & (38495)
yaxollum@fedora ~ main $ xonsh: there is an unfinished job
-----
[1]+ running: sleep 500 & & (38495)
-----
Type "exit" or press "ctrl-d" again to force quit.
yaxollum@fedora ~ main $
yaxollum@fedora ~ main $ xonsh: there is an unfinished job
-----
[1]+ running: sleep 500 & & (38495)
-----
Type "exit" or press "ctrl-d" again to force quit.
yaxollum@fedora ~ main $
yaxollum@fedora ~ main $ xonsh: there is an unfinished job
-----
[1]+ running: sleep 500 & & (38495)
-----
Type "exit" or press "ctrl-d" again to force quit.
yaxollum@fedora ~ main $
yaxollum@fedora ~ main $
```

### Workaround
In step 2 above, if I type `exit` instead of pressing Ctrl+D, the shell exits after another `exit` command or Ctrl+D

## xonfig

```
+------------------+-------------------------+
| xonsh            | 0.12.4                  |
| Git SHA          | 57f1e4bc                |
| Commit Date      | May 19 23:15:03 2022    |
| Python           | 3.9.12                  |
| PLY              | 3.11                    |
| have readline    | True                    |
| prompt toolkit   | 3.0.28                  |
| shell type       | prompt_toolkit          |
| history backend  | sqlite                  |
| pygments         | 2.9.0                   |
| on posix         | True                    |
| on linux         | True                    |
| distro           | fedora                  |
| on wsl           | False                   |
| 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         |
| xontrib          | []                      |
| RC file 1        | /home/yaxollum/.xonshrc |
+------------------+-------------------------+
```




## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**



Discussion:
appears that this issue was introduced by https://github.com/xonsh/xonsh/commit/404cbf5452a984688a3f8c10522497af4fe2e3cd (I'll try to open a PR to fix it)

Resolving patch:
diff --git a/xonsh/base_shell.py b/xonsh/base_shell.py
--- a/xonsh/base_shell.py
+++ b/xonsh/base_shell.py
@@ -511,7 +511,12 @@ def compile(self, src):
             return src, None
         try:
             code = self.execer.compile(
-                src, mode="single", glbs=self.ctx, locs=None, filename="<stdin>"
+                src,
+                mode="single",
+                glbs=self.ctx,
+                locs=None,
+                filename="<stdin>",
+                compile_empty_tree=False,
             )
             if _cache:
                 update_cache(code, cachefname)
diff --git a/xonsh/execer.py b/xonsh/execer.py
--- a/xonsh/execer.py
+++ b/xonsh/execer.py
@@ -112,6 +112,7 @@ def compile(
         stacklevel=2,
         filename=None,
         transform=True,
+        compile_empty_tree=True,
     ):
         """Compiles xonsh code into a Python code object, which may then
         be execed or evaled.
@@ -128,7 +129,9 @@ def compile(
         ctx = set(dir(builtins)) | set(glbs.keys()) | set(locs.keys())
         tree = self.parse(input, ctx, mode=mode, filename=filename, transform=transform)
         if tree is None:
-            return compile("pass", filename, mode)  # handles comment only input
+            return (
+                compile("pass", filename, mode) if compile_empty_tree else None
+            )  # handles comment only input
         try:
             code = compile(tree, filename, mode)
         except SyntaxError as e:
diff --git a/xonsh/ptk_shell/shell.py b/xonsh/ptk_shell/shell.py
--- a/xonsh/ptk_shell/shell.py
+++ b/xonsh/ptk_shell/shell.py
@@ -188,6 +188,7 @@ def __init__(self, **kwargs):
             winutils.enable_virtual_terminal_processing()
         self._first_prompt = True
         self.history = ThreadedHistory(PromptToolkitHistory())
+        self.push = self._push
 
         ptk_args.setdefault("history", self.history)
         if not XSH.env.get("XONSH_COPY_ON_DELETE", False):
@@ -380,7 +381,9 @@ def _push(self, line):
         src = "".join(self.buffer)
         src = transform_command(src)
         try:
-            code = self.execer.compile(src, mode="single", glbs=self.ctx, locs=None)
+            code = self.execer.compile(
+                src, mode="single", glbs=self.ctx, locs=None, compile_empty_tree=False
+            )
             self.reset_buffer()
         except Exception:  # pylint: disable=broad-except
             self.reset_buffer()
@@ -393,7 +396,6 @@ def cmdloop(self, intro=None):
         if intro:
             print(intro)
         auto_suggest = AutoSuggestFromHistory()
-        self.push = self._push
         while not XSH.exit:
             try:
                 line = self.singleline(auto_suggest=auto_suggest)


Test patch:
diff --git a/tests/test_base_shell.py b/tests/test_base_shell.py
--- a/tests/test_base_shell.py
+++ b/tests/test_base_shell.py
@@ -1,6 +1,8 @@
 """(A down payment on) Testing for ``xonsh.base_shell.BaseShell`` and associated classes"""
 import os
 
+import pytest
+
 from xonsh.base_shell import BaseShell
 from xonsh.shell import transform_command
 
@@ -36,3 +38,28 @@ def spam2egg(cmd, **_):
     assert transform_command("spam") == "egg"
     assert transform_command("egg") == "egg"
     assert transform_command("foo") == "foo"
+
+
+@pytest.mark.parametrize(
+    "cmd,exp_append_history",
+    [
+        ("", False),
+        ("# a comment", False),
+        ("print('yes')", True),
+    ],
+)
+def test_default_append_history(cmd, exp_append_history, xonsh_session, monkeypatch):
+    """Test that running an empty line or a comment does not append to history"""
+    append_history_calls = []
+
+    def mock_append_history(**info):
+        append_history_calls.append(info)
+
+    monkeypatch.setattr(
+        xonsh_session.shell.shell, "_append_history", mock_append_history
+    )
+    xonsh_session.shell.default(cmd)
+    if exp_append_history:
+        assert len(append_history_calls) == 1
+    else:
+        assert len(append_history_calls) == 0
diff --git a/tests/test_imphooks.py b/tests/test_imphooks.py
--- a/tests/test_imphooks.py
+++ b/tests/test_imphooks.py
@@ -20,6 +20,12 @@ def test_import():
     assert "hello mom jawaka\n" == sample.x
 
 
+def test_import_empty():
+    from xpack import empty_xsh
+
+    assert empty_xsh
+
+
 def test_absolute_import():
     from xpack import sample
 
diff --git a/tests/test_ptk_shell.py b/tests/test_ptk_shell.py
--- a/tests/test_ptk_shell.py
+++ b/tests/test_ptk_shell.py
@@ -121,3 +121,28 @@ def test_ptk_prompt(line, exp, ptk_shell, capsys):
     out = screen.display[0].strip()
 
     assert out.strip() == (exp or line)
+
+
+@pytest.mark.parametrize(
+    "cmd,exp_append_history",
+    [
+        ("", False),
+        ("# a comment", False),
+        ("print('yes')", True),
+    ],
+)
+def test_ptk_default_append_history(cmd, exp_append_history, ptk_shell, monkeypatch):
+    """Test that running an empty line or a comment does not append to history.
+    This test is necessary because the prompt-toolkit shell uses a custom _push() method that is different from the base shell's push() method."""
+    inp, out, shell = ptk_shell
+    append_history_calls = []
+
+    def mock_append_history(**info):
+        append_history_calls.append(info)
+
+    monkeypatch.setattr(shell, "_append_history", mock_append_history)
+    shell.default(cmd)
+    if exp_append_history:
+        assert len(append_history_calls) == 1
+    else:
+        assert len(append_history_calls) == 0
diff --git a/tests/xpack/empty_xsh.xsh b/tests/xpack/empty_xsh.xsh
new file mode 100644