99
1010A session cancel (Ctrl-C) kills the process group and delivers a
1111cancelled error.
12+
13+ Output is read incrementally with a bounded buffer (head + tail), so a
14+ huge stream (e.g. ``cat`` of a multi-GB log) can never exhaust memory,
15+ and delivery never waits on a detached child that keeps the stdout
16+ pipe open after the shell has exited.
17+
18+ Normal completion appends ``Exit code: N`` (N negative = killed by a
19+ signal). A command that produces no output for ``BASH_TIMEOUT_SILENCE``
20+ seconds is killed (SIGTERM then SIGKILL) and reported as timed out;
21+ ``BASH_TIMEOUT_MAX`` optionally caps the total runtime. Ctrl-C kills
22+ the process group immediately.
1223"""
1324
1425from __future__ import annotations
1526
27+ import codecs
1628import contextlib
1729import os
30+ import select
1831import signal
1932import subprocess
2033import threading
34+ import time
35+ from collections import deque
2136
37+ from ..config import BASH_TIMEOUT_MAX , BASH_TIMEOUT_SILENCE
38+ from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
2239from .base import PendingToolResult , Tool , ToolContext
2340
24- # Maximum output size (chars) before truncation. Matches the 20 KB
25- # spool threshold used by Glob/Grep/Read so that a model-issued
26- # command cannot exhaust memory.
27- _MAX_OUTPUT = 20_000
41+ # Tail lines kept after truncation (the head budget is derived from
42+ # MAX_OUTPUT_CHARS, shared with the filesystem spool threshold).
2843_TAIL_LINES = 50 # lines kept from the tail after truncation
44+ _READ_CHUNK = 64 * 1024 # bytes read from the pipe per iteration
45+ _POLL_INTERVAL = 0.02 # seconds between cancel/exit checks
46+ _DRAIN_GRACE = 0.25 # seconds to keep reading after the process exits
47+
48+
49+ def _kill_pgid (pgid : int ) -> None :
50+ """Kill the process group PGID, ignoring "already gone" errors.
51+
52+ The group id is captured at spawn (with ``start_new_session=True``
53+ the child is the group leader, so its pid IS the pgid): it must
54+ never be resolved at kill time via ``os.getpgid`` — by then the
55+ shell may already be dead while a detached child that keeps the
56+ stdout pipe open is still running in the group.
57+ """
58+ with contextlib .suppress (ProcessLookupError , PermissionError , OSError ):
59+ os .killpg (pgid , signal .SIGKILL )
2960
3061
31- def _kill_process ( proc : subprocess .Popen ) -> None :
32- """Kill PROC and its whole process group .
62+ def _kill_graceful ( pgid : int , proc : subprocess .Popen ) -> None :
63+ """SIGTERM the process group; SIGKILL if it is still alive 2s later .
3364
34- The process is started in its own session so a shell's children
35- (e.g. `sleep 30` spawned by the shell) die too; otherwise they keep
36- the stdout pipe open and ``communicate()`` blocks until they exit.
65+ Used for timeouts so shells/compilers get a chance to clean up
66+ children before the hard kill.
3767 """
68+ with contextlib .suppress (ProcessLookupError , PermissionError , OSError ):
69+ os .killpg (pgid , signal .SIGTERM )
3870 try :
39- os .killpg (os .getpgid (proc .pid ), signal .SIGKILL )
40- except (ProcessLookupError , PermissionError , OSError ):
41- with contextlib .suppress (ProcessLookupError ):
42- proc .kill ()
71+ proc .wait (timeout = 2 )
72+ except subprocess .TimeoutExpired :
73+ _kill_pgid (pgid )
74+
75+
76+ def _timeout_message (out : str , silence : bool ) -> str :
77+ """Report a timed-out command, preserving any output so far."""
78+ if silence :
79+ timeout = BASH_TIMEOUT_SILENCE or 0.0
80+ reason = f"no output for { timeout :.0f} s"
81+ else :
82+ timeout = BASH_TIMEOUT_MAX or 0.0
83+ reason = f"exceeded the { timeout :.0f} s maximum"
84+ out = out .rstrip ("\n " )
85+ suffix = f"Error: Bash command timed out ({ reason } )."
86+ return f"{ out } \n \n { suffix } " if out else suffix
87+
88+
89+ def _append_exit_code (out : str , proc : subprocess .Popen ) -> str :
90+ """Append ``Exit code: N`` (N negative = killed by a signal).
91+
92+ The exit-code line is added AFTER truncation, so it is always the
93+ last line and survives the head+tail retention.
94+ """
95+ try :
96+ rc = proc .wait (timeout = 2 )
97+ except subprocess .TimeoutExpired :
98+ return out # still alive; nothing useful to report
99+ if out and not out .endswith ("\n " ):
100+ out += "\n "
101+ return f"{ out } Exit code: { rc } "
102+
103+
104+ def _assemble_truncated (head : str , tail : deque [str ]) -> str :
105+ """Assemble a truncated output within the cap.
106+
107+ ``head`` holds the first ``_MAX_OUTPUT`` chars, ``tail`` the last
108+ ``_TAIL_LINES`` lines (each already line-capped). The tail is
109+ preferred: as many trailing lines as fit are kept and the head gets
110+ the remaining budget, so the delivered string never exceeds
111+ ``_MAX_OUTPUT`` (plus the truncation notice).
112+ """
113+ notice = f"... [truncated: output exceeded { _MAX_OUTPUT } chars] ..."
114+ budget = _MAX_OUTPUT - len (notice ) - 4 # room for the "\n\n" separators
115+ tail_parts : list [str ] = []
116+ used = 0
117+ for line in reversed (tail ):
118+ cost = len (line ) + (1 if tail_parts else 0 )
119+ if used + cost > budget :
120+ break
121+ tail_parts .append (line )
122+ used += cost
123+ head = head [: max (0 , budget - used )]
124+ out = f"{ head } \n \n { notice } "
125+ if tail_parts :
126+ out += "\n \n " + "\n " .join (reversed (tail_parts ))
127+ return out
128+
129+
130+ def _collect_output (proc : subprocess .Popen , cancel : threading .Event | None ) -> tuple [str , str ]:
131+ """Read PROC's merged output incrementally; return (text, status).
132+
133+ Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
134+ ``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars) and
135+ the tail (last ``_TAIL_LINES`` lines) and discards the middle, so
136+ memory stays bounded no matter how much the process writes. The
137+ read loop is poll-based: a cancel is noticed promptly, a process
138+ silent for ``BASH_TIMEOUT_SILENCE`` seconds (or running past
139+ ``BASH_TIMEOUT_MAX``) is reported as timed out, and a process that
140+ has exited is only drained for ``_DRAIN_GRACE`` seconds — a
141+ detached child holding the pipe open can never wedge delivery.
142+ """
143+ stdout = proc .stdout
144+ if stdout is None : # unreachable (stdout=PIPE), kept for the type checker
145+ return "" , "ok"
146+ fd = stdout .fileno ()
147+ os .set_blocking (fd , False )
148+ decoder = codecs .getincrementaldecoder ("utf-8" )(errors = "replace" )
149+ head : list [str ] = []
150+ head_len = 0
151+ tail : deque [str ] = deque (maxlen = _TAIL_LINES )
152+ pending_line = ""
153+ total = 0
154+ exited = False
155+ drain_until : float | None = None
156+ start = time .monotonic ()
157+ last_output = start
158+
159+ def finish () -> str :
160+ nonlocal pending_line
161+ if pending_line :
162+ if len (pending_line ) > _MAX_OUTPUT :
163+ pending_line = pending_line [:_MAX_OUTPUT ]
164+ tail .append (pending_line )
165+ head_text = "" .join (head )
166+ if total > _MAX_OUTPUT :
167+ return _assemble_truncated (head_text , tail )
168+ return head_text
169+
170+ while True :
171+ if cancel is not None and cancel .is_set ():
172+ return "" , "cancelled"
173+ now = time .monotonic ()
174+ if not exited :
175+ if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE :
176+ return finish (), "timeout_silence"
177+ if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX :
178+ return finish (), "timeout_max"
179+ if exited and drain_until is not None and now >= drain_until :
180+ break
181+ readable , _ , _ = select .select ([fd ], [], [], _POLL_INTERVAL )
182+ if not readable :
183+ if not exited and proc .poll () is not None :
184+ exited = True
185+ drain_until = time .monotonic () + _DRAIN_GRACE
186+ continue
187+ try :
188+ raw = os .read (fd , _READ_CHUNK )
189+ except BlockingIOError :
190+ continue
191+ except OSError :
192+ break
193+ if not raw :
194+ break # EOF: every writer closed the pipe
195+ chunk = decoder .decode (raw )
196+ total += len (chunk )
197+ last_output = time .monotonic ()
198+ if head_len < _MAX_OUTPUT :
199+ take = chunk [: _MAX_OUTPUT - head_len ]
200+ head .append (take )
201+ head_len += len (take )
202+ parts = chunk .split ("\n " )
203+ parts [0 ] = pending_line + parts [0 ]
204+ pending_line = parts .pop ()
205+ for line in parts :
206+ if len (line ) > _MAX_OUTPUT :
207+ line = line [:_MAX_OUTPUT ]
208+ tail .append (line )
209+ return finish (), "ok"
43210
44211
45212class Bash (Tool ):
46213 name = "Bash"
214+ _timeout_silence = BASH_TIMEOUT_SILENCE
215+ _timeout_note = (
216+ f"A command silent for { _timeout_silence :.0f} s is killed and reported as timed out. "
217+ if _timeout_silence is not None
218+ else ""
219+ )
47220 description = (
48- "Execute a shell command. Returns stdout, or an error string. "
49- "A session cancel (Ctrl-C) kills the process."
221+ "Execute a shell command. Returns stdout followed by 'Exit code: N' "
222+ "(N is the command's exit status; negative means killed by a signal). "
223+ + _timeout_note
224+ + "A session cancel (Ctrl-C) kills the process."
50225 )
51226 parameters = {
52227 "type" : "object" ,
@@ -61,60 +236,63 @@ def run(self, args: dict, ctx: ToolContext) -> str | PendingToolResult:
61236 return self ._execute (command , ctx )
62237
63238 def _execute (self , command : str , ctx : ToolContext ) -> str | PendingToolResult :
239+ cancel = ctx .cancel_event
240+ if cancel is not None and cancel .is_set ():
241+ # Ctrl-C already pending: do not spawn a process that would
242+ # be killed moments later.
243+ return "Error: Bash command cancelled."
64244 try :
65245 proc = subprocess .Popen (
66246 command ,
67247 shell = True ,
68248 stdin = subprocess .DEVNULL ,
69249 stdout = subprocess .PIPE ,
70250 stderr = subprocess .STDOUT ,
71- text = True ,
72- encoding = "utf-8" ,
73- errors = "replace" ,
251+ bufsize = 0 ,
74252 cwd = ctx .cwd ,
75253 start_new_session = True ,
76254 )
77255 except OSError as e :
78256 return f"Error: { e } "
79257
258+ # start_new_session=True makes the child the session/group
259+ # leader, so its pid IS the group id — captured once here,
260+ # never resolved again at kill time.
261+ pgid = proc .pid
80262 pending = PendingToolResult ()
81- cancel = ctx .cancel_event
82- done = threading .Event ()
83- killed = threading .Event ()
84-
85- def watcher () -> None :
86- """Kill the process group when the session is cancelled."""
87- while True :
88- if done .wait (0.05 ):
89- return
90- if cancel is not None and cancel .is_set ():
91- killed .set ()
92- _kill_process (proc )
93- return
94263
95264 def deliverer () -> None :
96- """Collect output; deliver it once the process exits."""
265+ """Collect output; deliver it once the process exits.
266+
267+ The kill happens HERE (not in a separate watcher thread):
268+ the collector is the thread that observed the condition, so
269+ there is no race window in which a watcher exits without
270+ killing and the process group survives. Cancel is an
271+ immediate SIGKILL; a timeout kills gracefully (SIGTERM,
272+ then SIGKILL after 2s) so children get a chance to clean up.
273+ """
97274 try :
98- out , _ = proc . communicate ( )
275+ out , status = _collect_output ( proc , cancel )
99276 except Exception as e : # noqa: BLE001 - delivered as an error string
100277 out = f"Error: Bash failed — { e } "
101- finally :
102- done .set ()
103- if killed .is_set ():
104- pending .deliver ("Error: Bash command cancelled." )
105278 else :
106- out = out or ""
107- if len (out ) > _MAX_OUTPUT :
108- # Keep head + tail so the model sees the start and end
109- tail = "\n " .join (out .splitlines ()[- _TAIL_LINES :])
110- head_budget = _MAX_OUTPUT - len (tail ) - 200 # room for notice
111- head = out [: max (head_budget , 1000 )]
112- out = (
113- f"{ head } \n \n ... [truncated: output exceeded "
114- f"{ _MAX_OUTPUT } chars] ...\n \n { tail } "
115- )
116- pending .deliver (out )
117-
118- threading .Thread (target = watcher , daemon = True ).start ()
279+ if status == "cancelled" :
280+ _kill_pgid (pgid )
281+ out = "Error: Bash command cancelled."
282+ elif status == "timeout_silence" :
283+ _kill_graceful (pgid , proc )
284+ out = _timeout_message (out , silence = True )
285+ elif status == "timeout_max" :
286+ _kill_graceful (pgid , proc )
287+ out = _timeout_message (out , silence = False )
288+ elif status == "ok" :
289+ out = _append_exit_code (out , proc )
290+ pending .deliver (out )
291+ with contextlib .suppress (Exception ):
292+ if proc .stdout is not None :
293+ proc .stdout .close ()
294+ with contextlib .suppress (Exception ):
295+ proc .wait (timeout = 2 ) # reap (bounded; never wedges)
296+
119297 threading .Thread (target = deliverer , daemon = True ).start ()
120298 return pending
0 commit comments