Fix code execution PTY reset hangs
Start local PTY shells in a new process session and make close() escalate from SIGTERM to SIGKILL when a foreground command refuses to exit. This keeps code_execution_tool reset from blocking indefinitely on stuck terminal processes.\n\nAdd regression coverage for closing a TERM-resistant shell and document the reset lifecycle contract.
Alessandro committed
Jul 9, 2026 at 03:29 UTC
f1787b65f5914f611d353a3d6b47d43baecfd032
3 files changed
+51
-15
plugins/_code_execution/AGENTS.md
+1
@@ -14,6 +14,7 @@
14
## Local Contracts
15
16
- Keep session concurrency, timeout, streaming, and reset behavior predictable.
17
+- Terminal reset/close must not hang on foreground commands or shells that ignore SIGTERM.
18
- Explicitly target local versus SSH execution runtimes.
19
- Do not hardcode secrets, SSH credentials, or local user paths.
20
plugins/_code_execution/helpers/tty_session.py
+36
-14
@@ -1,10 +1,12 @@
1
-import asyncio, os, sys, platform, errno
1
+import asyncio, os, sys, platform, errno, signal
2
3
_IS_WIN = platform.system() == "Windows"
4
if _IS_WIN:
5
import winpty # pip install pywinpty # type: ignore
6
import msvcrt
7
8
+_CLOSE_TIMEOUT_SECONDS = 2
9
+
10
11
def _reconfigure_stream_errors(stream) -> None:
12
reconfigure = getattr(stream, "reconfigure", None)
@@ -77,15 +79,16 @@ class TTYSession:
79
80
# Terminate the process if it exists
81
if self._proc:
82
+ if getattr(self._proc, "returncode", None) is None:
83
+ self._signal_process(signal.SIGTERM)
84
try:
81
- if getattr(self._proc, "returncode", None) is None:
82
- self._proc.terminate()
83
- except ProcessLookupError:
84
- pass
85
- except Exception:
86
- pass
87
- try:
88
- await self._proc.wait()
85
+ await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
86
+ except asyncio.TimeoutError:
87
+ self._signal_process(signal.SIGKILL)
88
+ try:
89
+ await asyncio.wait_for(self._proc.wait(), _CLOSE_TIMEOUT_SECONDS)
90
+ except Exception:
91
+ pass
92
except Exception:
93
pass
94
@@ -93,6 +96,28 @@ class TTYSession:
96
self._proc = None
97
self._pump_task = None
98
99
+ def _signal_process(self, sig):
100
+ if self._proc is None:
101
+ return
102
+ try:
103
+ if _IS_WIN:
104
+ if sig == signal.SIGKILL:
105
+ self._proc.kill()
106
+ else:
107
+ self._proc.terminate()
108
+ return
109
+ os.killpg(self._proc.pid, sig)
110
+ except ProcessLookupError:
111
+ pass
112
+ except Exception:
113
+ try:
114
+ if sig == signal.SIGKILL:
115
+ self._proc.kill()
116
+ else:
117
+ self._proc.terminate()
118
+ except Exception:
119
+ pass
120
+
121
def _release_pty_master(self):
122
"""Release the POSIX PTY master exactly once.
123
@@ -166,11 +191,7 @@ class TTYSession:
191
192
# Only attempt to kill if the process is still running
193
if getattr(self._proc, "returncode", None) is None:
169
- try:
170
- self._proc.kill()
171
- except ProcessLookupError:
172
- # Child already gone – treat as successfully killed
173
- pass
194
+ self._signal_process(signal.SIGKILL)
195
self._release_pty_master()
196
197
async def read(self, timeout=None):
@@ -241,6 +262,7 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
262
cwd=cwd,
263
env=env,
264
close_fds=True,
265
+ start_new_session=True,
266
)
267
os.close(slave)
268
tests/test_code_execution_pager.py
+14
-1
@@ -1,10 +1,13 @@
1
-"""Regression tests for issue #1697.
1
+"""Regression tests for code execution shell lifecycle behavior.
2
3
Pagers (more/less) must be disabled in the non-interactive shells created by the
4
code execution tool: without user input they block forever and spin at 100% CPU.
5
"""
6
7
+import asyncio
8
+
9
from plugins._code_execution.helpers import shell_local, shell_ssh
10
+from plugins._code_execution.helpers.tty_session import TTYSession
11
12
13
def test_local_env_disables_pagers_and_preserves_existing():
@@ -30,3 +33,13 @@ def test_local_env_does_not_mutate_input():
33
def test_ssh_command_disables_pagers():
34
assert "GIT_PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND
35
assert "PAGER=cat" in shell_ssh.PAGER_DISABLE_COMMAND
36
+
37
+
38
+def test_tty_close_kills_term_resistant_process():
39
+ async def run():
40
+ session = TTYSession("bash -lc 'trap \"\" TERM; sleep 30'")
41
+ await session.start()
42
+ await asyncio.wait_for(session.close(), timeout=6)
43
+ assert session._proc is None
44
+
45
+ asyncio.run(run())