Skip to content

Commit 0e2bb1a

Browse files
authored
Create bash_win.py
1 parent 07fa86f commit 0e2bb1a

1 file changed

Lines changed: 232 additions & 0 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
"""Windows Bash tool: process management for win32.
2+
3+
Windows lacks ``os.killpg``, ``os.set_blocking``, ``select.select``
4+
on pipe fds, and ``start_new_session``. This subclass overrides
5+
``_execute`` to use Windows-native process creation and termination
6+
while inheriting the shared output-truncation, timeout, and exit-code
7+
helpers from :class:`Bash`.
8+
9+
Process creation uses ``CREATE_NEW_PROCESS_GROUP`` so the child is
10+
isolated from console Ctrl-C signals (the TUI handles cancel itself).
11+
Process termination uses ``taskkill /F /T /PID`` to kill the process
12+
and all its children (the Windows equivalent of killing a process
13+
group). Output is collected via a reader thread + queue, since
14+
``select.select`` cannot poll Windows pipes.
15+
16+
Only the process-creation and output-collection strategy differs;
17+
the tool name, description, parameters, timeout semantics, and
18+
exit-code reporting are inherited so callers, the tool registry,
19+
and the plan-mode write guard are platform-independent.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import codecs
25+
import contextlib
26+
import os
27+
import queue
28+
import subprocess
29+
import threading
30+
import time
31+
from collections import deque
32+
33+
from ..config import BASH_TIMEOUT_MAX, BASH_TIMEOUT_SILENCE
34+
from ..config import MAX_OUTPUT_CHARS as _MAX_OUTPUT
35+
from .base import PendingToolResult, ToolContext
36+
from .bash import (
37+
_DRAIN_GRACE,
38+
_POLL_INTERVAL,
39+
_READ_CHUNK,
40+
_TAIL_LINES,
41+
Bash,
42+
_append_exit_code,
43+
_assemble_truncated,
44+
_timeout_message,
45+
)
46+
47+
48+
def _kill_process_tree(pid: int) -> None:
49+
"""Kill the process PID and all its children (Windows equivalent of
50+
``os.killpg`` with ``SIGKILL``).
51+
52+
Uses ``taskkill /F /T /PID`` which recursively terminates the
53+
process tree. Errors are suppressed (already gone, etc.).
54+
"""
55+
with contextlib.suppress(Exception):
56+
subprocess.run(
57+
["taskkill", "/F", "/T", "/PID", str(pid)],
58+
stdin=subprocess.DEVNULL,
59+
stdout=subprocess.DEVNULL,
60+
stderr=subprocess.DEVNULL,
61+
timeout=10,
62+
)
63+
64+
65+
def _kill_process_tree_graceful(pid: int, proc: subprocess.Popen) -> None:
66+
"""Graceful kill: terminate, then taskkill /F /T if still alive.
67+
68+
Windows has no SIGTERM equivalent for process trees;
69+
``proc.terminate()`` calls ``TerminateProcess`` on the immediate
70+
child. If it doesn't die within 2s, ``taskkill /F /T`` force-kills
71+
the whole tree (children included).
72+
"""
73+
with contextlib.suppress(ProcessLookupError, OSError):
74+
proc.terminate()
75+
try:
76+
proc.wait(timeout=2)
77+
except subprocess.TimeoutExpired:
78+
_kill_process_tree(pid)
79+
80+
81+
def _collect_output_win(proc: subprocess.Popen, cancel: threading.Event | None) -> tuple[str, str]:
82+
"""Read PROC's merged output on Windows; return (text, status).
83+
84+
Windows lacks ``select.select`` on pipe fds and ``os.set_blocking``,
85+
so a reader thread does blocking ``os.read`` calls and feeds chunks
86+
into a ``queue.Queue``. The collector polls the queue with
87+
``_POLL_INTERVAL``, checking cancel/timeout conditions between
88+
reads — mirroring the Unix ``_collect_output`` logic exactly.
89+
90+
Status is one of ``"ok"``, ``"cancelled"``, ``"timeout_silence"``,
91+
``"timeout_max"``. Keeps the head (first ``_MAX_OUTPUT`` chars)
92+
and the tail (last ``_TAIL_LINES`` lines) and discards the middle,
93+
so memory stays bounded no matter how much the process writes.
94+
"""
95+
stdout = proc.stdout
96+
if stdout is None:
97+
return "", "ok"
98+
99+
chunk_queue: queue.Queue[bytes | None] = queue.Queue()
100+
101+
def _reader() -> None:
102+
"""Blocking reader thread: reads raw bytes and feeds the queue."""
103+
try:
104+
fd = stdout.fileno()
105+
while True:
106+
raw = os.read(fd, _READ_CHUNK)
107+
if not raw:
108+
break
109+
chunk_queue.put(raw)
110+
except OSError:
111+
pass
112+
finally:
113+
chunk_queue.put(None)
114+
115+
threading.Thread(target=_reader, daemon=True).start()
116+
117+
decoder = codecs.getincrementaldecoder("utf-8")(errors="replace")
118+
head: list[str] = []
119+
head_len = 0
120+
tail: deque[str] = deque(maxlen=_TAIL_LINES)
121+
pending_line = ""
122+
total = 0
123+
exited = False
124+
drain_until: float | None = None
125+
start = time.monotonic()
126+
last_output = start
127+
128+
def finish() -> str:
129+
nonlocal pending_line
130+
if pending_line:
131+
if len(pending_line) > _MAX_OUTPUT:
132+
pending_line = pending_line[:_MAX_OUTPUT]
133+
tail.append(pending_line)
134+
head_text = "".join(head)
135+
if total > _MAX_OUTPUT:
136+
return _assemble_truncated(head_text, tail)
137+
return head_text
138+
139+
while True:
140+
if cancel is not None and cancel.is_set():
141+
return "", "cancelled"
142+
now = time.monotonic()
143+
if not exited:
144+
if BASH_TIMEOUT_SILENCE is not None and now - last_output >= BASH_TIMEOUT_SILENCE:
145+
return finish(), "timeout_silence"
146+
if BASH_TIMEOUT_MAX is not None and now - start >= BASH_TIMEOUT_MAX:
147+
return finish(), "timeout_max"
148+
if exited and drain_until is not None and now >= drain_until:
149+
break
150+
try:
151+
raw = chunk_queue.get(timeout=_POLL_INTERVAL)
152+
except queue.Empty:
153+
if not exited and proc.poll() is not None:
154+
exited = True
155+
drain_until = time.monotonic() + _DRAIN_GRACE
156+
continue
157+
if raw is None:
158+
break
159+
chunk = decoder.decode(raw)
160+
total += len(chunk)
161+
last_output = time.monotonic()
162+
if head_len < _MAX_OUTPUT:
163+
take = chunk[: _MAX_OUTPUT - head_len]
164+
head.append(take)
165+
head_len += len(take)
166+
parts = chunk.split("\n")
167+
parts[0] = pending_line + parts[0]
168+
pending_line = parts.pop()
169+
for line in parts:
170+
if len(line) > _MAX_OUTPUT:
171+
line = line[:_MAX_OUTPUT]
172+
tail.append(line)
173+
return finish(), "ok"
174+
175+
176+
class BashWindows(Bash):
177+
"""Bash for Windows: ``CREATE_NEW_PROCESS_GROUP`` + ``taskkill``.
178+
179+
Overrides :meth:`_execute` to use Windows-native process creation
180+
(``CREATE_NEW_PROCESS_GROUP``) and termination (``taskkill /F /T``).
181+
All shared logic — timeout messages, output truncation, exit-code
182+
reporting, the ``PendingToolResult`` async contract — is inherited
183+
unchanged from :class:`Bash`.
184+
"""
185+
186+
def _execute(self, command: str, ctx: ToolContext) -> str | PendingToolResult:
187+
cancel = ctx.cancel_event
188+
if cancel is not None and cancel.is_set():
189+
return "Error: Bash command cancelled."
190+
try:
191+
proc = subprocess.Popen(
192+
command,
193+
shell=True,
194+
stdin=subprocess.DEVNULL,
195+
stdout=subprocess.PIPE,
196+
stderr=subprocess.STDOUT,
197+
bufsize=0,
198+
cwd=ctx.cwd,
199+
creationflags=getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0),
200+
)
201+
except OSError as e:
202+
return f"Error: {e}"
203+
204+
pid = proc.pid
205+
pending = PendingToolResult()
206+
207+
def deliverer() -> None:
208+
try:
209+
out, status = _collect_output_win(proc, cancel)
210+
except Exception as e: # noqa: BLE001 - delivered as an error string
211+
out = f"Error: Bash failed — {e}"
212+
else:
213+
if status == "cancelled":
214+
_kill_process_tree(pid)
215+
out = "Error: Bash command cancelled."
216+
elif status == "timeout_silence":
217+
_kill_process_tree_graceful(pid, proc)
218+
out = _timeout_message(out, silence=True)
219+
elif status == "timeout_max":
220+
_kill_process_tree_graceful(pid, proc)
221+
out = _timeout_message(out, silence=False)
222+
elif status == "ok":
223+
out = _append_exit_code(out, proc)
224+
pending.deliver(out)
225+
with contextlib.suppress(Exception):
226+
if proc.stdout is not None:
227+
proc.stdout.close()
228+
with contextlib.suppress(Exception):
229+
proc.wait(timeout=2)
230+
231+
threading.Thread(target=deliverer, daemon=True).start()
232+
return pending

0 commit comments

Comments
 (0)