fix(code_execution): avoid double-close of PTY master fd

Use a shared mutable holder for the POSIX PTY master fd and invalidate it before close. This keeps EOF cleanup and TTYSession.close()/kill() idempotent and prevents closing an unrelated resource if the OS reuses the old fd number.

Agent Zero Local committed May 2, 2026 at 20:56 UTC d4eaa7c0305eea10d97694971d747df1e891eb40
1 file changed +74 -40
plugins/_code_execution/helpers/tty_session.py
+74 -40
@@ -25,6 +25,7 @@ class TTYSession:
25 self._buf: asyncio.Queue = None # type: ignore
26 self._pump_task = None
27 self._pty_master = None
28 + self._pty_master_ref = None
29
30 def __del__(self):
31 # Simple cleanup on object destruction
@@ -48,7 +49,12 @@ class TTYSession:
49 self._proc = await _spawn_posix_pty(
50 self.cmd, self.cwd, self.env, self.echo
51 ) # ← pass echo
51 - self._pty_master = getattr(self._proc, "_pty_master", None)
52 + self._pty_master_ref = getattr(self._proc, "_pty_master_ref", None)
53 + self._pty_master = (
54 + self._pty_master_ref.get("fd")
55 + if self._pty_master_ref is not None
56 + else getattr(self._proc, "_pty_master", None)
57 + )
58 self._pump_task = asyncio.create_task(self._pump_stdout())
59
60 async def close(self):
@@ -62,14 +68,6 @@ class TTYSession:
68 except Exception:
69 pass
70
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 -
71 # Terminate the process if it exists
72 if self._proc:
73 try:
@@ -84,21 +82,47 @@ class TTYSession:
82 except Exception:
83 pass
84
87 - if master is not None:
88 - try:
89 - os.close(master)
90 - except OSError:
91 - pass
92 -
85 + self._release_pty_master()
86 self._proc = None
87 self._pump_task = None
88 +
89 + def _release_pty_master(self):
90 + """Release the POSIX PTY master exactly once.
91 +
92 + The fd number is invalidated before os.close() so that a concurrent or
93 + later cleanup path cannot close the same integer after the OS has reused
94 + it for another file/socket.
95 + """
96 + ref = self._pty_master_ref
97 + master = ref.get("fd") if ref is not None else self._pty_master
98 + if master is None:
99 + self._pty_master = None
100 + return
101 + if ref is not None:
102 + ref["fd"] = None
103 self._pty_master = None
104 + try:
105 + loop = asyncio.get_running_loop()
106 + loop.remove_reader(master)
107 + except Exception:
108 + pass
109 + try:
110 + os.close(master)
111 + except OSError:
112 + pass
113 + self._pty_master_ref = None
114
115 async def send(self, data: str | bytes):
116 if self._proc is None:
117 raise RuntimeError("TTYSpawn is not started")
100 - if self._pty_master is None and not _IS_WIN:
101 - raise RuntimeError("TTYSpawn PTY is closed")
118 + if not _IS_WIN:
119 + master = (
120 + self._pty_master_ref.get("fd")
121 + if self._pty_master_ref is not None
122 + else self._pty_master
123 + )
124 + if master is None:
125 + raise RuntimeError("TTYSpawn PTY is closed")
126 if getattr(self._proc, "returncode", None) is not None:
127 raise RuntimeError("TTYSpawn process has exited")
128 if isinstance(data, str):
@@ -108,7 +132,7 @@ class TTYSession:
132 await self._proc.stdin.drain() # type: ignore
133 except OSError as e:
134 if e.errno in (errno.EBADF, errno.EIO, errno.EINVAL):
111 - self._pty_master = None
135 + self._release_pty_master()
136 raise RuntimeError("TTYSpawn PTY is closed") from e
137 raise
138
@@ -140,18 +164,7 @@ class TTYSession:
164 except ProcessLookupError:
165 # Child already gone – treat as successfully killed
166 pass
143 - master = self._pty_master
144 - if master is not None:
145 - try:
146 - loop = asyncio.get_running_loop()
147 - loop.remove_reader(master)
148 - except Exception:
149 - pass
150 - try:
151 - os.close(master)
152 - except OSError:
153 - pass
154 - self._pty_master = None
167 + self._release_pty_master()
168
169 async def read(self, timeout=None):
170 # Return any decoded text the child produced, or None on timeout
@@ -226,10 +239,34 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
239
240 loop = asyncio.get_running_loop()
241 reader = asyncio.StreamReader()
242 + master_ref = {"fd": master}
243 +
244 + def _release_master_fd():
245 + cur = master_ref.get("fd")
246 + if cur is None:
247 + return
248 + # Invalidate before close so later cleanup cannot close a reused fd.
249 + master_ref["fd"] = None
250 + try:
251 + proc._pty_master = None # type: ignore[attr-defined]
252 + except Exception:
253 + pass
254 + try:
255 + loop.remove_reader(cur)
256 + except Exception:
257 + pass
258 + try:
259 + os.close(cur)
260 + except OSError:
261 + pass
262
263 def _on_data():
264 + cur = master_ref.get("fd")
265 + if cur is None:
266 + reader.feed_eof()
267 + return
268 try:
232 - data = os.read(master, 1 << 16)
269 + data = os.read(cur, 1 << 16)
270 except OSError as e:
271 if e.errno != errno.EIO: # EIO == EOF on some systems
272 raise
@@ -238,20 +275,16 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
275 reader.feed_data(data)
276 else:
277 reader.feed_eof()
241 - try:
242 - loop.remove_reader(master)
243 - except Exception:
244 - pass
245 - try:
246 - os.close(master)
247 - except OSError:
248 - pass
278 + _release_master_fd()
279
280 loop.add_reader(master, _on_data)
281
282 class _Stdin:
283 def write(self, d):
254 - os.write(master, d)
284 + cur = master_ref.get("fd")
285 + if cur is None:
286 + raise OSError(errno.EBADF, "PTY master closed")
287 + os.write(cur, d)
288
289 async def drain(self):
290 await asyncio.sleep(0)
@@ -259,6 +292,7 @@ async def _spawn_posix_pty(cmd, cwd, env, echo):
292 proc.stdin = _Stdin() # type: ignore
293 proc.stdout = reader
294 proc._pty_master = master # type: ignore[attr-defined]
295 + proc._pty_master_ref = master_ref # type: ignore[attr-defined]
296 return proc
297
298