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())