Repository: xonsh/xonsh

Issue:
ctrl-z problem on Linux
Launching a command (like emacs) followed by ctrl-z suspends
the process but doesn't return keyboard control to xonsh. So there
is no way to detach the process with bg and continue typing. The
documentation only mentions that ctrl-z doesn't work on Windows.

xonfig
+------------------+----------+
| xonsh | 0.3.2 |
| Python | 3.4.3 |
| PLY | 3.7 |
| have readline | True |
| prompt toolkit | None |
| shell type | readline |
| pygments | None |
| on posix | True |
| on linux | True |
| distro | Fedora |
| on darwin | False |
| on windows | False |
| is superuser | False |
| default encoding | utf-8 |
+------------------+----------+


Discussion:

Patch:
diff --git a/news/ctrlz.rst b/news/ctrlz.rst
new file mode 100644
index 00000000..fa8c5d38
--- /dev/null
+++ b/news/ctrlz.rst
@@ -0,0 +1,27 @@
+**Added:**
+
+* New ``$THREAD_SUBPROCS`` environment variable allows you to
+  specify whether threadable subprocesses should actually be
+  run in a thread or not.  Default ``True``.
+
+**Changed:**
+
+* <news item>
+
+**Deprecated:**
+
+* <news item>
+
+**Removed:**
+
+* <news item>
+
+**Fixed:**
+
+* Pressing ``Ctrl+Z`` no longer deadlocks the terminal,
+  allowing further input from the user, even for threaded
+  subprocesses.
+
+**Security:**
+
+* <news item>
diff --git a/xonsh/built_ins.py b/xonsh/built_ins.py
index 586c0ad9..2794a226 100644
--- a/xonsh/built_ins.py
+++ b/xonsh/built_ins.py
@@ -738,7 +738,10 @@ class SubprocSpec:
         if not callable(alias):
             return
         self.is_proxy = True
-        thable = getattr(alias, "__xonsh_threadable__", True)
+        env = builtins.__xonsh__.env
+        thable = env.get("THREAD_SUBPROCS") and getattr(
+            alias, "__xonsh_threadable__", True
+        )
         cls = ProcProxyThread if thable else ProcProxy
         self.cls = cls
         self.threadable = thable
@@ -790,6 +793,7 @@ def _safe_pipe_properties(fd, use_tty=False):
 
 
 def _update_last_spec(last):
+    env = builtins.__xonsh__.env
     captured = last.captured
     last.last_in_pipeline = True
     if not captured:
@@ -799,9 +803,11 @@ def _update_last_spec(last):
         pass
     else:
         cmds_cache = builtins.__xonsh__.commands_cache
-        thable = cmds_cache.predict_threadable(
-            last.args
-        ) and cmds_cache.predict_threadable(last.cmd)
+        thable = (
+            env.get("THREAD_SUBPROCS")
+            and cmds_cache.predict_threadable(last.args)
+            and cmds_cache.predict_threadable(last.cmd)
+        )
         if captured and thable:
             last.cls = PopenThread
         elif not thable:
diff --git a/xonsh/environ.py b/xonsh/environ.py
index 500ce14e..6bc3e2d3 100644
--- a/xonsh/environ.py
+++ b/xonsh/environ.py
@@ -571,6 +571,7 @@ def DEFAULT_ENSURERS():
         "SUGGEST_MAX_NUM": (is_int, int, str),
         "SUGGEST_THRESHOLD": (is_int, int, str),
         "SUPPRESS_BRANCH_TIMEOUT_MESSAGE": (is_bool, to_bool, bool_to_str),
+        "THREAD_SUBPROCS": (is_bool, to_bool, bool_to_str),
         "UPDATE_COMPLETIONS_ON_KEYPRESS": (is_bool, to_bool, bool_to_str),
         "UPDATE_OS_ENVIRON": (is_bool, to_bool, bool_to_str),
         "UPDATE_PROMPT_ON_KEYPRESS": (is_bool, to_bool, bool_to_str),
@@ -755,6 +756,7 @@ def DEFAULT_VALUES():
         "SUGGEST_COMMANDS": True,
         "SUGGEST_MAX_NUM": 5,
         "SUGGEST_THRESHOLD": 3,
+        "THREAD_SUBPROCS": True,
         "TITLE": DEFAULT_TITLE,
         "UPDATE_COMPLETIONS_ON_KEYPRESS": False,
         "UPDATE_OS_ENVIRON": False,
@@ -1159,6 +1161,26 @@ def DEFAULT_DOCS():
             "not always happen.",
             configurable=False,
         ),
+        "THREAD_SUBPROCS": VarDocs(
+            "Whether or not to try to run subrocess mode in a Python thread, "
+            "when applicable. There are various trade-offs, which normally "
+            "affects only interactive sessions.\n\nWhen True:\n\n"
+            "* Xonsh is able capture & store the stdin, stdout, and stderr \n"
+            "  of threadable subprocesses.\n"
+            "* However, stopping threaded subprocs with ^Z (i.e. ``SIGTSTP``)\n"
+            "  is disabled as it causes deadlocked terminals.\n"
+            "  ``SIGTSTP`` may still be issued and only the physical pressing\n"
+            "  of ``Ctrl+Z`` is ignored.\n"
+            "* Threadable commands are run with ``PopenThread`` and threadable \n"
+            "  aliases are run with ``ProcProxyThread``.\n\n"
+            "When False:\n\n"
+            "* Xonsh may not be able to capture stdin, stdout, and stderr streams \n"
+            "  unless explicitly asked to do so.\n"
+            "* Stopping the thread with ``Ctrl+Z`` yields to job control.\n"
+            "* Threadable commands are run with ``Popen`` and threadable \n"
+            "  alias are run with ``ProcProxy``.\n\n"
+            "The desired effect is often up to the command, user, or use case."
+        ),
         "TITLE": VarDocs(
             "The title text for the window in which xonsh is running. Formatted "
             "in the same manner as ``$PROMPT``, see 'Customizing the Prompt' "
diff --git a/xonsh/jobs.py b/xonsh/jobs.py
index ec984db1..20d5923b 100644
--- a/xonsh/jobs.py
+++ b/xonsh/jobs.py
@@ -202,7 +202,6 @@ else:
                     last_task=active_task, backgrounded=backgrounded
                 )
         if os.WIFSTOPPED(wcode):
-            print("^Z")
             active_task["status"] = "stopped"
             backgrounded = True
         elif os.WIFSIGNALED(wcode):
diff --git a/xonsh/proc.py b/xonsh/proc.py
index 9c778e9c..3c2899fe 100644
--- a/xonsh/proc.py
+++ b/xonsh/proc.py
@@ -554,6 +554,8 @@ class PopenThread(threading.Thread):
         self.timeout = env.get("XONSH_PROC_FREQUENCY")
         self.in_alt_mode = False
         self.stdin_mode = None
+        self._tc_cc_vsusp = b"\x1a"  # default is usually ^Z
+        self._disable_suspend_keybind()
         # stdout setup
         self.orig_stdout = stdout
         self.stdout_fd = 1 if stdout is None else stdout.fileno()
@@ -820,6 +822,33 @@ class PopenThread(threading.Thread):
             self.old_tstp_handler = None
         if frame is not None:
             self._disable_cbreak_stdin()
+        self._restore_suspend_keybind()
+
+    def _disable_suspend_keybind(self):
+        if ON_WINDOWS:
+            return
+        try:
+            mode = termios.tcgetattr(0)  # only makes sense for stdin
+        except termios.error:
+            return
+        self._tc_cc_vsusp = mode[CC][termios.VSUSP]
+        mode[CC][termios.VSUSP] = b"\x00"  # set ^Z (ie SIGSTOP) to undefined
+        termios.tcsetattr(0, termios.TCSANOW, mode)
+
+    def _restore_suspend_keybind(self):
+        if ON_WINDOWS:
+            return
+        try:
+            mode = termios.tcgetattr(0)  # only makes sense for stdin
+        except termios.error:
+            return
+        mode[CC][termios.VSUSP] = self._tc_cc_vsusp  # set ^Z (ie SIGSTOP) to original
+        try:
+            # this usually doesn't work in interactive mode,
+            # but we should try it anyway.
+            termios.tcsetattr(0, termios.TCSANOW, mode)
+        except termios.error:
+            pass
 
     #
     # SIGQUIT handler
@@ -1749,6 +1778,7 @@ def SIGNAL_MESSAGES():
         signal.SIGILL: "Illegal instructions",
         signal.SIGTERM: "Terminated",
         signal.SIGSEGV: "Segmentation fault",
+        signal.SIGTSTP: "Stopped",
     }
     if ON_POSIX:
         sm.update(
diff --git a/xonsh/ptk2/key_bindings.py b/xonsh/ptk2/key_bindings.py
index c3e78dc0..c68f9acb 100644
--- a/xonsh/ptk2/key_bindings.py
+++ b/xonsh/ptk2/key_bindings.py
@@ -350,3 +350,10 @@ def load_xonsh_bindings(key_bindings):
     @handle(Keys.ControlJ, filter=IsSearching())
     def accept_search(event):
         search.accept_search()
+
+    @handle(Keys.ControlZ)
+    def skip_control_z(event):
+        """Prevents the writing of ^Z to the prompt, if Ctrl+Z was pressed
+        during the previous command.
+        """
+        pass
diff --git a/xonsh/ptk2/shell.py b/xonsh/ptk2/shell.py
index d8d699f5..ec8b3f26 100644
--- a/xonsh/ptk2/shell.py
+++ b/xonsh/ptk2/shell.py
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 """The prompt_toolkit based xonsh shell."""
+import os
 import sys
 import builtins
 from types import MethodType
@@ -8,7 +9,7 @@ from xonsh.events import events
 from xonsh.base_shell import BaseShell
 from xonsh.shell import transform_command
 from xonsh.tools import print_exception, carriage_return
-from xonsh.platform import HAS_PYGMENTS, ON_WINDOWS
+from xonsh.platform import HAS_PYGMENTS, ON_WINDOWS, ON_POSIX
 from xonsh.style_tools import partial_color_tokenize, _TokenType, DEFAULT_STYLE_DICT
 from xonsh.lazyimps import pygments, pyghooks, winutils
 from xonsh.pygments_cache import get_all_styles
@@ -366,3 +367,9 @@ class PromptToolkit2Shell(BaseShell):
         #   if not ON_POSIX:
         #       return
         #   sys.stdout.write('\033[9999999C\n')
+        if not ON_POSIX:
+            return
+        stty, _ = builtins.__xonsh__.commands_cache.lazyget("stty", (None, None))
+        if stty is None:
+            return
+        os.system(stty + " sane")
diff --git a/xontrib/bashisms.py b/xontrib/bashisms.py
index 567602aa..9f620023 100644
--- a/xontrib/bashisms.py
+++ b/xontrib/bashisms.py
@@ -2,6 +2,7 @@
 import shlex
 import sys
 import re
+import builtins
 
 
 __all__ = ()
@@ -62,3 +63,4 @@ def alias(args, stdin=None):
 
 
 aliases["alias"] = alias
+builtins.__xonsh__.env["THREAD_SUBPROCS"] = False


Test patch:
diff --git a/tests/test_builtins.py b/tests/test_builtins.py
index 29950df6..36dcd2c5 100644
--- a/tests/test_builtins.py
+++ b/tests/test_builtins.py
@@ -6,6 +6,7 @@ import re
 import builtins
 import types
 from ast import AST, Module, Interactive, Expression
+from subprocess import Popen
 
 import pytest
 
@@ -25,8 +26,10 @@ from xonsh.built_ins import (
     in_macro_call,
     call_macro,
     enter_macro,
+    cmds_to_specs,
 )
 from xonsh.environ import Env
+from xonsh.proc import PopenThread, ProcProxy, ProcProxyThread
 
 from tools import skip_if_on_windows
 
@@ -388,3 +391,28 @@ def test_enter_macro():
     assert obj.macro_block == "wakka"
     assert obj.macro_globals
     assert obj.macro_locals
+
+
+@skip_if_on_windows
+def test_cmds_to_specs_thread_subproc(xonsh_builtins):
+    env = xonsh_builtins.__xonsh__.env
+    cmds = [["pwd"]]
+    # First check that threadable subprocs become threadable
+    env['THREAD_SUBPROCS'] = True
+    specs = cmds_to_specs(cmds, captured='hiddenobject')
+    assert specs[0].cls is PopenThread
+    # turn off threading and check we use Popen
+    env['THREAD_SUBPROCS'] = False
+    specs = cmds_to_specs(cmds, captured='hiddenobject')
+    assert specs[0].cls is Popen
+
+    # now check the threadbility of callable aliases
+    cmds = [[lambda: "Keras Selyrian"]]
+    # check that threadable alias become threadable
+    env['THREAD_SUBPROCS'] = True
+    specs = cmds_to_specs(cmds, captured='hiddenobject')
+    assert specs[0].cls is ProcProxyThread
+    # turn off threading and check we use ProcProxy
+    env['THREAD_SUBPROCS'] = False
+    specs = cmds_to_specs(cmds, captured='hiddenobject')
+    assert specs[0].cls is ProcProxy