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