Skip to content

Commit 2a20689

Browse files
Fix Windows terminal Ctrl-C process cleanup (#3171)
Co-authored-by: openhands <openhands@all-hands.dev>
1 parent 1a884f7 commit 2a20689

3 files changed

Lines changed: 174 additions & 3 deletions

File tree

openhands-tools/openhands/tools/terminal/terminal/windows_terminal.py

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
_SETUP_DELAY_SECONDS = 0.5
3333
_SETUP_POLL_INTERVAL_SECONDS = 0.05
3434
_MAX_SETUP_WAIT_SECONDS = 2.0
35+
_INTERRUPT_GRACE_SECONDS = 0.5
3536

3637
_WINDOWS_SPECIALS: dict[str, str] = {
3738
"ENTER": "\n",
@@ -172,6 +173,7 @@ def close(self) -> None:
172173
return
173174

174175
self._stop_reader.set()
176+
self._terminate_child_processes()
175177

176178
if self.process is not None:
177179
try:
@@ -327,6 +329,62 @@ def clear_screen(self) -> None:
327329
time.sleep(_SCREEN_CLEAR_DELAY_SECONDS)
328330
self._command_running_event.clear()
329331

332+
def _terminate_child_processes(self) -> bool:
333+
"""Terminate descendants of the persistent PowerShell process."""
334+
if (
335+
platform.system() != "Windows"
336+
or self.process is None
337+
or self.process.poll() is not None
338+
):
339+
return False
340+
341+
script = f"""
342+
$root = {self.process.pid}
343+
$childrenByParent = @{{}}
344+
Get-CimInstance Win32_Process | ForEach-Object {{
345+
$parentId = [int]$_.ParentProcessId
346+
if (-not $childrenByParent.ContainsKey($parentId)) {{
347+
$childrenByParent[$parentId] = New-Object System.Collections.Generic.List[int]
348+
}}
349+
$childrenByParent[$parentId].Add([int]$_.ProcessId)
350+
}}
351+
$toStop = New-Object System.Collections.Generic.List[int]
352+
function Add-Descendants([int]$processId) {{
353+
if (-not $childrenByParent.ContainsKey($processId)) {{ return }}
354+
foreach ($childId in $childrenByParent[$processId]) {{
355+
if ($childId -eq $PID) {{ continue }}
356+
$toStop.Add($childId)
357+
Add-Descendants $childId
358+
}}
359+
}}
360+
Add-Descendants $root
361+
for ($i = $toStop.Count - 1; $i -ge 0; $i--) {{
362+
Stop-Process -Id $toStop[$i] -Force -ErrorAction SilentlyContinue
363+
}}
364+
if ($toStop.Count -gt 0) {{ exit 0 }} else {{ exit 1 }}
365+
"""
366+
startupinfo = None
367+
startupinfo_cls = getattr(subprocess, "STARTUPINFO", None)
368+
if startupinfo_cls is not None:
369+
startupinfo = startupinfo_cls()
370+
startupinfo.dwFlags |= getattr(subprocess, "STARTF_USESHOWWINDOW", 0)
371+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0)
372+
373+
try:
374+
result = subprocess.run(
375+
[self.shell_path, "-NoLogo", "-NoProfile", "-Command", script],
376+
stdout=subprocess.DEVNULL,
377+
stderr=subprocess.DEVNULL,
378+
check=False,
379+
timeout=5.0,
380+
startupinfo=startupinfo,
381+
creationflags=creationflags,
382+
)
383+
return result.returncode == 0
384+
except (subprocess.TimeoutExpired, OSError) as exc:
385+
logger.debug("Failed to terminate PowerShell child processes: %s", exc)
386+
return False
387+
330388
def interrupt(self) -> bool:
331389
"""Interrupt the active command if the process is still alive."""
332390
if self.process is None or self.process.poll() is not None:
@@ -341,15 +399,21 @@ def interrupt(self) -> bool:
341399
except Exception as exc:
342400
logger.debug("Failed to send CTRL_BREAK_EVENT: %s", exc)
343401

344-
if not sent_ctrl_break:
402+
if sent_ctrl_break:
403+
time.sleep(_INTERRUPT_GRACE_SECONDS)
404+
405+
terminated_children = self._terminate_child_processes()
406+
sent_ctrl_c_input = False
407+
if not sent_ctrl_break and not terminated_children:
345408
try:
346409
self._write_to_stdin(_WINDOWS_SPECIALS["C-C"])
410+
sent_ctrl_c_input = True
347411
except RuntimeError as exc:
348412
logger.debug("Failed to write Ctrl+C to PowerShell stdin: %s", exc)
349413
return False
350414

351415
self._command_running_event.clear()
352-
return True
416+
return sent_ctrl_break or terminated_children or sent_ctrl_c_input
353417

354418
def is_running(self) -> bool:
355419
"""Return whether a command is still running in the PowerShell session."""

tests/tools/browser_use/test_browser_toolset.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,8 @@ def _mock_browser_executor_init():
3131
def fake_init(self, **_kwargs):
3232
self.full_output_save_dir = None
3333
self._initialized = False
34-
self._cleanup_initiated = False
34+
# Toolset tests never allocate browser resources; keep close() a no-op.
35+
self._cleanup_initiated = True
3536
self._action_timeout_seconds = 30.0
3637
self._async_executor = MagicMock()
3738
self._async_executor.close = MagicMock()
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Windows-specific terminal interrupt behavior tests."""
2+
3+
import platform
4+
import subprocess
5+
6+
import pytest
7+
8+
from openhands.tools.terminal.definition import TerminalAction
9+
from openhands.tools.terminal.terminal import create_terminal_session
10+
from openhands.tools.terminal.terminal.terminal_session import TerminalCommandStatus
11+
12+
13+
pytestmark = pytest.mark.skipif(
14+
platform.system() != "Windows",
15+
reason="Windows CTRL_BREAK/PowerShell process behavior only applies on Windows",
16+
)
17+
18+
19+
def _powershell_process_exists(pid: int) -> bool:
20+
result = subprocess.run(
21+
[
22+
"powershell.exe",
23+
"-NoLogo",
24+
"-NoProfile",
25+
"-Command",
26+
(
27+
f"if (Get-Process -Id {pid} -ErrorAction SilentlyContinue) "
28+
"{ exit 0 } else { exit 1 }"
29+
),
30+
],
31+
stdout=subprocess.DEVNULL,
32+
stderr=subprocess.DEVNULL,
33+
check=False,
34+
)
35+
return result.returncode == 0
36+
37+
38+
def _stop_powershell_process(pid: int) -> None:
39+
subprocess.run(
40+
[
41+
"powershell.exe",
42+
"-NoLogo",
43+
"-NoProfile",
44+
"-Command",
45+
f"Stop-Process -Id {pid} -Force -ErrorAction SilentlyContinue",
46+
],
47+
stdout=subprocess.DEVNULL,
48+
stderr=subprocess.DEVNULL,
49+
check=False,
50+
)
51+
52+
53+
@pytest.mark.timeout(20)
54+
def test_windows_ctrl_c_interrupt_kills_child_process_tree(tmp_path) -> None:
55+
"""Ctrl-C after a timeout should stop the process that kept the command alive.
56+
57+
This captures the behavior promised by the timeout prompt. The current
58+
PowerShell backend sends CTRL_BREAK to the persistent PowerShell process, but
59+
does not ensure child processes launched by the command are terminated.
60+
"""
61+
pid_path = tmp_path / "child.pid"
62+
script_path = tmp_path / "wait_on_child.ps1"
63+
script_path.write_text(
64+
"\n".join(
65+
[
66+
f"$pidPath = '{pid_path.as_posix()}'",
67+
"$child = Start-Process -FilePath powershell.exe "
68+
"-ArgumentList '-NoLogo','-NoProfile','-Command',"
69+
"'Start-Sleep -Seconds 120' -PassThru",
70+
"Set-Content -LiteralPath $pidPath -Value $child.Id",
71+
"Wait-Process -Id $child.Id",
72+
]
73+
),
74+
encoding="utf-8",
75+
)
76+
77+
session = create_terminal_session(
78+
work_dir=str(tmp_path),
79+
terminal_type="powershell",
80+
no_change_timeout_seconds=1,
81+
)
82+
child_pid: int | None = None
83+
child_was_still_running = False
84+
try:
85+
session.initialize()
86+
87+
obs = session.execute(TerminalAction(command=f"& '{script_path.as_posix()}'"))
88+
89+
assert obs.metadata.exit_code == -1
90+
assert session.prev_status == TerminalCommandStatus.NO_CHANGE_TIMEOUT
91+
assert pid_path.exists()
92+
child_pid = int(pid_path.read_text(encoding="utf-8").strip())
93+
assert _powershell_process_exists(child_pid)
94+
95+
session.execute(TerminalAction(command="C-c", is_input=True, timeout=3))
96+
97+
child_was_still_running = _powershell_process_exists(child_pid)
98+
finally:
99+
if child_pid is not None:
100+
_stop_powershell_process(child_pid)
101+
session.close()
102+
103+
assert not child_was_still_running, (
104+
"Windows Ctrl-C reported through the terminal did not terminate the "
105+
"child process that kept the timed-out command alive."
106+
)

0 commit comments

Comments
 (0)