fix(code_execution): close PTY file descriptors

Store and close POSIX PTY master descriptors when terminal sessions are closed or killed, and make local terminal session shutdown await the full TTY cleanup path. This prevents leaked /dev/ptmx descriptors from exhausting the process file descriptor limit.

Agent Zero Local committed May 2, 2026 at 13:07 UTC 2ce1947b0fc02ac12a8817a1804ff5b40abe5f8a
2 files changed +57 -6
plugins/_code_execution/helpers/shell_local.py
+2 -2
@@ -21,8 +21,8 @@ class LocalInteractiveSession:
21
22 async def close(self):
23 if self.session:
24 - self.session.kill()
25 - # self.session.wait()
24 + await self.session.close()
25 + self.session = None
26
27 async def send_command(self, command: str):
28 if not self.session:
plugins/_code_execution/helpers/tty_session.py
+55 -4
@@ -23,6 +23,8 @@ class TTYSession:
23 self.echo = echo # ← store preference
24 self._proc = None
25 self._buf: asyncio.Queue = None # type: ignore
26 + self._pump_task = None
27 + self._pty_master = None
28
29 def __del__(self):
30 # Simple cleanup on object destruction
@@ -46,22 +48,51 @@ class TTYSession:
48 self._proc = await _spawn_posix_pty(
49 self.cmd, self.cwd, self.env, self.echo
50 ) # ← pass echo
51 + self._pty_master = getattr(self._proc, "_pty_master", None)
52 self._pump_task = asyncio.create_task(self._pump_stdout())
53
54 async def close(self):
55 # Cancel the pump task if it exists
53 - if hasattr(self, "_pump_task") and self._pump_task:
56 + if self._pump_task:
57 self._pump_task.cancel()
58 try:
59 await self._pump_task
60 except asyncio.CancelledError:
61 pass
62 + except Exception:
63 + pass
64 +
65 + master = self._pty_master
66 + if master is not None:
67 + try:
68 + loop = asyncio.get_running_loop()
69 + loop.remove_reader(master)
70 + except Exception:
71 + pass
72 +
73 # Terminate the process if it exists
74 if self._proc:
61 - self._proc.terminate()
62 - await self._proc.wait()
75 + try:
76 + if getattr(self._proc, "returncode", None) is None:
77 + self._proc.terminate()
78 + except ProcessLookupError:
79 + pass
80 + except Exception:
81 + pass
82 + try:
83 + await self._proc.wait()
84 + except Exception:
85 + pass
86 +
87 + if master is not None:
88 + try:
89 + os.close(master)
90 + except OSError:
91 + pass
92 +
93 self._proc = None
94 self._pump_task = None
95 + self._pty_master = None
96
97 async def send(self, data: str | bytes):
98 if self._proc is None:
@@ -99,6 +130,18 @@ class TTYSession:
130 except ProcessLookupError:
131 # Child already gone – treat as successfully killed
132 pass
133 + master = self._pty_master
134 + if master is not None:
135 + try:
136 + loop = asyncio.get_running_loop()
137 + loop.remove_reader(master)
138 + except Exception:
139 + pass
140 + try:
141 + os.close(master)
142 + except OSError:
143 + pass
144 + self._pty_master = None
145
146 async def read(self, timeout=None):
147 # Return any decoded text the child produced, or None on timeout
@@ -185,7 +228,14 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
228 reader.feed_data(data)
229 else:
230 reader.feed_eof()
188 - loop.remove_reader(master)
231 + try:
232 + loop.remove_reader(master)
233 + except Exception:
234 + pass
235 + try:
236 + os.close(master)
237 + except OSError:
238 + pass
239
240 loop.add_reader(master, _on_data)
241
@@ -198,6 +248,7 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
248
249 proc.stdin = _Stdin() # type: ignore
250 proc.stdout = reader
251 + proc._pty_master = master # type: ignore[attr-defined]
252 return proc
253
254