Repository: xonsh/xonsh
Pull request: feat: #5745 - send Ctrl+C event on Windows instead of forceful terminate
<!---

Thanks for opening a PR on xonsh!

Please do this:

1. Include a news file with your PR (https://xon.sh/devguide.html#changelog).
2. Add the documentation for your feature into `/docs`.
3. Add the example of usage or before-after behavior.
4. Mention the issue that this PR is addressing e.g. `#1234`.

-->

Resolve #5745. The existing behavior [was introduced in 2015](https://github.com/xonsh/xonsh/commit/3aa37682f9441bb7c8bfb492325c88e0900ed327#diff-75c83180c6250bfa7c142ed214f8c71e7704872f3b0c63b167895cf0ae892989R106-R107) ~~and I suspect was because there was no equivalent to [signal.CTRL_C_EVENT](https://docs.python.org/3/library/signal.html#signal.CTRL_C_EVENT) at the time in Python 2.7, it was introduced in Python 3.2.~~ Xonsh only supported 3.4+ at the time and [signal.CTRL_C_EVENT](https://docs.python.org/3/library/signal.html#signal.CTRL_C_EVENT) was already available; it's unclear why the existing implementation choice was made by @adqm .

# Before behavior:
When pressing Ctrl+C on Windows a forceful terminate was sent using the taskkill command.
```
localhost@ ssh remote
remote$ hello <Press Ctrl+C>
Return 1
localhost@
```
# After behavior:
When pressing Ctrl+C on Windows a [signal.CTRL_C_EVENT](https://docs.python.org/3/library/signal.html#signal.CTRL_C_EVENT) will be sent instead.
```
localhost@ ssh remote
remote$ hello^C
remote$ 
```

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**


Problem statement:
Pressing Ctrl-C in Windows cancels a subprocess
## Current Behavior
<!---
For general xonsh issues, please try to replicate the failure using `xonsh --no-rc --no-env`.
Short, reproducible code snippets are highly appreciated.
You can use `$XONSH_SHOW_TRACEBACK=1`, `$XONSH_TRACE_SUBPROC=2`, or `$XONSH_DEBUG=1`
to collect more information about the failure.
-->
When in a Xonsh interactive session running a subprocess such as ssh, pressing Ctrl-C will cause the subprocess to cancel instead of forwarding the SIGINT to the subprocess.

```
localhost @ ssh remotehost
remotehost $ man grep
...
   Matcher Selection
       -E, --extended-regexp
              Interpret PATTERN as an extended regular expression (ERE, see below).

 Manual page grep(1) line 1 (press h for help or q to quit)
...
<Press Ctrl-C>
 Return 1
<Xonsh exits SSH session with exit code: 1>
```

## Expected Behavior
<!--- What you expect and what is your real life use case. -->

The SIGINT should be forwarded to the subprocess instead of canceling the subprocess.

## xonfig

<details>

```xsh
@ xonfig
+-----------------------------+---------------------------+
| xonsh                       | 0.19.0                    |
| Python                      | 3.13.1                    |
| PLY                         | 3.11                      |
| have readline               | False                     |
| prompt toolkit              | 3.0.48                    |
| shell type                  | prompt_toolkit            |
| history backend             | sqlite                    |
| pygments                    | 2.18.0                    |
| on posix                    | False                     |
| on linux                    | False                     |
| on darwin                   | False                     |
| on windows                  | True                      |
| on cygwin                   | False                     |
| on msys2                    | False                     |
| is superuser                | False                     |
| default encoding            | utf-8                     |
| xonsh encoding              | utf-8                     |
| encoding errors             | surrogateescape           |
| xontrib 1                   | base16_shell              |
| xontrib 2                   | prompt_bar                |
| xontrib 3                   | pygitstatus               |
| xontrib 4                   | vox                       |
| xontrib 5                   | voxapi                    |
| xontrib 6                   | commands                  |
| RC file 1                   | C:\Users\XXXXXX\.xonshrc |
| UPDATE_OS_ENVIRON           | False                     |
| XONSH_CAPTURE_ALWAYS        | False                     |
| XONSH_SUBPROC_OUTPUT_FORMAT | stream_lines              |
| THREAD_SUBPROCS             | True                      |
| XONSH_CACHE_SCRIPTS         | True                      |
+-----------------------------+---------------------------+
```

</details>

## For community
⬇️  **Please click the 👍 reaction instead of leaving a `+1` or 👍  comment**

Hints from issue discussion:
I couldn't reproduce this, unfortunately.  I ssh'd into a couple of different Ubuntu servers.  Ran top, or `man grep`.  Ctrl+C cancelled top and man.

```
xonfig
+-----------------------------+---------------------+
| xonsh                       | 0.19.0              |
| Git SHA                     | c059ac23            |
| Commit Date                 | Dec 9 14:41:03 2024 |
| Python                      | 3.13.1              |
| PLY                         | 3.11                |
| have readline               | False               |
| prompt toolkit              | None                |
| shell type                  | readline            |
| history backend             | json                |
| pygments                    | None                |
| on posix                    | False               |
| on linux                    | False               |
| on darwin                   | False               |
| on windows                  | True                |
| on cygwin                   | False               |
| on msys2                    | False               |
| is superuser                | True                |
| default encoding            | utf-8               |
| xonsh encoding              | utf-8               |
| encoding errors             | surrogateescape     |
| xontrib                     | []                  |
| RC file                     | []                  |
| UPDATE_OS_ENVIRON           | False               |
| XONSH_CAPTURE_ALWAYS        | False               |
| XONSH_SUBPROC_OUTPUT_FORMAT | stream_lines        |
| THREAD_SUBPROCS             | True                |
| XONSH_CACHE_SCRIPTS         | True                |
+-----------------------------+---------------------+
```
Related, I've been able to reproduce this without getting ssh involved, the following example uses less that's bundled with Git for Windows 2.41.0.windows.3

```
pipx run --no-cache --python "C:\Users\XXXXXX\.pyenv\pyenv-win\versions\3.13.1\python.exe" "xonsh[full]" --no-rc
@ less --help
...
  ESC-}  ^RightArrow   Right to last column displayed.
  ESC-{  ^LeftArrow    Left  to first column.
HELP -- Press RETURN for more, or q when done
:
<Press Ctrl-C>
Return 1
```
What if you run xonsh without pipx?
I realize arrow keys also get messed up afterwards, pressing `<Up Arrow>` on the keyboard appears to insert the characters `OA`.

```
<Launch conhost.exe>
> C:\Users\XXXXXX\.pyenv\pyenv-win\versions\3.13.1\python.exe -m venv --upgrade-deps xonsh-5745
> xonsh-5745\Scripts\activate.bat
> pip install --no-cache "xonsh[full]"
...
Successfully installed prompt-toolkit-3.0.48 pygments-2.18.0 pyperclip-1.9.0 setproctitle-1.3.4 ujson-5.10.0 wcwidth-0.2.13 xonsh-0.19.0
> where xonsh
C:\TMP\xonsh-5745\Scripts\xonsh.exe
> xonsh --no-rc
@ less --help
...
  g  <  ESC-<       *  Go to first line in file (or line N).
  G  >  ESC->       *  Go to last line in file (or line N).
  p  %              *  Go to beginning of file (or N percent into file).
HELP -- Press RETURN for more, or q when done
<Press Ctrl+C>
[1] @ xonfig

+-----------------------------+-----------------+
| xonsh                       | 0.19.0          |
| Python                      | 3.13.1          |
| PLY                         | 3.11            |
| have readline               | False           |
| prompt toolkit              | 3.0.48          |
| shell type                  | prompt_toolkit  |
| history backend             | json            |
| pygments                    | 2.18.0          |
| on posix                    | False           |
| on linux                    | False           |
| on darwin                   | False           |
| on windows                  | True            |
| on cygwin                   | False           |
| on msys2                    | False           |
| is superuser                | False           |
| default encoding            | utf-8           |
| xonsh encoding              | utf-8           |
| encoding errors             | surrogateescape |
| xontrib                     | []              |
| RC file                     | []              |
| UPDATE_OS_ENVIRON           | False           |
| XONSH_CAPTURE_ALWAYS        | False           |
| XONSH_SUBPROC_OUTPUT_FORMAT | stream_lines    |
| THREAD_SUBPROCS             | True            |
| XONSH_CACHE_SCRIPTS         | True            |
+-----------------------------+-----------------+
@ 
```
What terminal emulator are you using?

Hi Kyle.  I followed your steps, just with a standard Python 3.13.1, not one via pyenv-win.  

Ctrl+C just quits `less --help` (this doesn't happen in Bash alone, but is what I'd expect?) and messes up the arrow keys for me too in the Xonsh session (giving OA and OB), and also makes anything I type in the parent bash session invisible after quitting Xonsh.  

Andy, I've reproduced this behaviour in Windows Terminal, Alacritty and WezTerm.  

I copied this line from the Windows Terminal Bash profile: `C:/Program Files/Git/bin/bash.exe -i -l` and pasted it into the Alacritty and WezTerm config files (adjusting the latter into Lua).  So I think the upstream bash is in interactive mode.  Is it?
I observe several similar issues - https://www.google.com/search?q=windows+git+bash+interactive+mode - may be it will help.
> What terminal emulator are you using?

I've reproduced this in Windows Terminal and Conhost

My Windows Terminal Xonsh profile

```json
{
    "commandline": "C:\\Users\\XXXX\\.local\\bin\\xonsh.exe -DXONSH_PROMPT_HIGHLIGHT_EXECUTABLE=1",
    "guid": "{b7af3958-5b9a-4e3f-ad60-de1f84338908}",
    "hidden": false,
    "icon": "C:\\Users\\XXXX\\Pictures\\conch_pixel-128.png",
    "name": "xonsh",
    "opacity": 85,
    "startingDirectory": null,
    "useAcrylic": false
},
```
Is there a good native Windows executable to use as a test case to isolate if Git for Windows programs are effected only? `C:\Windows\System32\more.com` quits on `<Ctrl+C>` as part of expected behavior while commands like *nix's less and ssh do not.

Commit message: #5745 - send Ctrl+C event on Windows instead of forceful terminate

Patch to review:
diff --git a/xonsh/procs/jobs.py b/xonsh/procs/jobs.py
index 0c41a33fac..3befb9a894 100644
--- a/xonsh/procs/jobs.py
+++ b/xonsh/procs/jobs.py
@@ -214,6 +214,9 @@ def _kill(job):
             stderr=subprocess.STDOUT,
         )
 
+    def _ctrl_c(job):
+        os.kill(job["obj"].pid, signal.CTRL_C_EVENT)
+
     _hup = _kill  # there is no equivalent of SIGHUP on Windows
 
     def ignore_sigtstp():
@@ -239,10 +242,7 @@ def wait_for_active_job(last_task=None, backgrounded=False, return_error=False):
             except subprocess.TimeoutExpired:
                 pass
             except KeyboardInterrupt:
-                try:
-                    _kill(active_task)
-                except subprocess.CalledProcessError:
-                    pass  # ignore error if process closed before we got here
+                _ctrl_c(active_task)
         return wait_for_active_job(last_task=active_task)
 
 else:


Code review comments:
diff_hunk: @@ -239,10 +242,7 @@ def wait_for_active_job(last_task=None, backgrounded=False, return_error=False):
             except subprocess.TimeoutExpired:
                 pass
             except KeyboardInterrupt:
-                try:
-                    _kill(active_task)
-                except subprocess.CalledProcessError:
-                    pass  # ignore error if process closed before we got here
+                _ctrl_c(active_task)
path: xonsh/procs/jobs.py
text: @user1:
I actually don't think you need to send *anything*. Both the Python REPL and NeoVim (installed by winget) do their CTRL-C handling just fine on their own, tested both in Windows Terminal and in Command Prompt (cmd.exe).

When you do this `_ctrl_c()` here, processes get a duplicate CTRL-C in short succession. In case of Python 3.13 from the Windows Store it sends it into an infinite loop of KeyboardInterrupts.

Just saying `pass` here works fine for me.

@author:
Validated locally, thanks! 👍