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