Skip to content

fix(acp_adapter): windows bash hang - #69083

Closed
fangliquanflq wants to merge 8 commits into
NousResearch:mainfrom
fangliquanflq:fix/acp-adapter-windows-bash-hang
Closed

fangliquanflq wants to merge 8 commits into
NousResearch:mainfrom
fangliquanflq:fix/acp-adapter-windows-bash-hang

Conversation

@fangliquanflq

@fangliquanflq fangliquanflq commented Jul 22, 2026 •

Copy link
Copy Markdown

What does this PR do?

Symptom: On Windows, driving Hermes through an ACP client and asking it to write a file hung — the ACP session never progressed past the first tool path that needs a local shell.

Cause: That path runs tools/environments/local._bash_starts, which probed Git Bash with subprocess.run(..., timeout=15). After timeout, run()'s post-kill cleanup does an unbounded communicate(). If a suspended MSYS child (true / cat) still holds the captured pipe handles, the reader-thread join never returns, so the ACP write-file / tool turn looks wedged forever.

Same deadlock class as the shared git-probe fix (#68997). This PR extracts bounded_captured_run (Popen + tree-kill + bounded 1s drain, stdin=DEVNULL) and routes _bash_starts through it. bounded_git_probe becomes a thin fail-open wrapper on the same helper.

Related Issue

Fixes #73403

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Tests (adding or improving test coverage)

Changes Made

  • hermes_cli/_subprocess_compat.py — add bounded_captured_run; refactor bounded_git_probe onto it; generalize process-tree kill helper
  • tools/environments/local.py — _bash_starts() uses bounded_captured_run instead of subprocess.run(timeout=...)
  • tests/test_windows_subprocess_no_window_flags.py — spawn contract + Windows tree-kill coverage for bounded_captured_run
  • tests/tools/test_find_shell.py — _bash_starts uses bounded helper; timeout fails open; retained-stdin regression (POSIX)

How to Test

  1. scripts/run_tests.sh tests/tools/test_find_shell.py tests/test_windows_subprocess_no_window_flags.py -q (44 passed)
  2. Unit: mock Popen / bounded_captured_run and assert timeout path tree-kills + returns returncode=-1 without hanging
  3. Manual (Windows ACP client): ask Hermes to write a file; the turn must complete instead of hanging on the Git Bash startup probe

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature
  • I've added tests for my changes
  • I've tested on my platform: Windows 11 + WSL — scripts/run_tests.sh tests/tools/test_find_shell.py tests/test_windows_subprocess_no_window_flags.py (44 passed, 0 failed); reproduced hang via ACP client write-file before the fix

Documentation & Housekeeping

  • Documentation - N/A
  • cli-config.yaml.example - N/A
  • CONTRIBUTING.md / AGENTS.md - N/A
  • Cross-platform impact considered - yes (Windows ACP write-file hang; POSIX probe behavior unchanged)
  • Tool descriptions/schemas - N/A

Screenshots / Logs

N/A — hang is absence of progress after ACP asks Hermes to write a file; covered by bounded cleanup unit tests.

@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard tool/terminal Terminal execution and process management backend/local Local shell execution platform/windows P2 Medium — degraded but workaround exists sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows labels Jul 22, 2026

@fangliquanflq fangliquanflq left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok.

@OpenSpek

Copy link
Copy Markdown

Independent Windows 10 integration proof confirms this failure class in a real ACP host.

A disposable Buzz PR #2773 managed agent launched installed hermes-acp.exe 0.19.0 directly. Event routing and the openai-codex:gpt-5.6-sol request completed, but the first terminal call wedged at:

terminal_tool
_create_environment
LocalEnvironment.__init__
init_session
_find_bash
_bash_starts
subprocess.run
_communicate

The matching Git Bash probe child ran /usr/bin/true; /usr/bin/cat --version; an MSYS descendant retained captured handles after the launcher timeout. Eliminating pipe-backed timeout cleanup locally unblocked the turn, which then advanced to the next integration boundary and ultimately produced a visible, attributed Buzz reply.

So #69083 matches both the observed stack and process-lifecycle root cause. I have not claimed this PR's exact implementation was exercised locally; this is independent reproduction evidence supporting its diagnosis and bounded tree-kill approach.

@fangliquanflq

Copy link
Copy Markdown
Author

@teknium1 @OutThisLife please approve Actions so CI can run

@monerostar monerostar left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

monerostar native Windows live-verify

Environment

  • Windows 11 native (Build 26200) · Git Bash at %LOCALAPPDATA%\hermes\git\bin\bash.exe
  • Control install: _bash_starts still uses subprocess.run(..., timeout=15) (unbounded post-kill communicate() class)
  • PR HEAD: b21dcec87 — worktree pr-69083
  • CI: required checks pass
  • Related symptom class: ACP / local shell hang on Windows (also see open issue #73403)

Root cause (still present on main)

tools/environments/local.py _bash_starts probes Git Bash with external MSYS programs (true/cat). On timeout, stock subprocess.run cleanup can hang forever after kill when MSYS children keep pipes open. PR switches the probe to shared bounded_captured_run (tree-kill + bounded 1s drain) already used for git probes — helper exists on main in hermes_cli/_subprocess_compat.py; call site was the gap.

Live probe (healthy path)

main this PR
_bash_starts(bash.exe) True ~0.078s True ~0.076s
cached second call — True ~0.000s
implementation subprocess.run bounded_captured_run

Did not force a hang repro (would need a stuck MSYS child); the control/path difference is the important part and matches the documented deadlock class.

Tests (PR tree)

pytest tests/test_windows_subprocess_no_window_flags.py -k "bounded or bash_starts or tree_kill or communicate" -q -o addopts=
→ 13 passed, 29 deselected

pytest tests/tools/test_find_shell.py -k "windows or bash_starts or bounded" -q -o addopts=
→ 4 passed, 1 skipped, 14 deselected

Broader test_find_shell.py + full no_window file: 55 passed, 3 failed on this host for reasons orthogonal to the hang fix:

  1. Two TestFindShellPrefersUserShell cases — real Git Bash on PATH wins over temp fake SHELL/bash stubs (environment / resolution order).
  2. test_suppress_platform_ver_console_posix_noop — asserts empty win32_ver under a posix-noop path; on real Win11 win32_ver is populated ('10', '10.0....').

Not treating those as regressions of this PR’s bash-probe change.

Assessment

  • Right fix, reuses existing bounded helper — prefer this over a third one-off timeout wrapper
  • Good match for ACP adapter Windows hang reports
  • CI green

Verdict: LGTM direction for the _bash_starts deadlock class.

Formal Approve: blocked for external collaborator — comment review only.

@teknium1

Copy link
Copy Markdown
Collaborator

Thanks for targeting the remaining Git Bash probe path. The premise still holds on current main: tools/environments/local.py:894-900 invokes subprocess.run(..., capture_output=True, timeout=15) for the external MSYS true/cat probe. Current main's hermes_cli/_subprocess_compat.py:413-439 already establishes the bounded Popen → tree-kill → one-second drain pattern for the same Windows failure class, and this PR applies it to the missing call site with focused coverage.

Suggested changes

  • This branch is now conflicting and 859 commits behind main. During maintainer salvage, retain the later noninteractive_git_env() hardening at hermes_cli/_subprocess_compat.py:307-344 while resolving the helper extraction conflict.

Automated hermes-sweeper review.

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
Replace _bash_starts subprocess.run(timeout=) with shared
bounded_captured_run so post-kill pipe drains cannot hang tool startup.
@fangliquanflq
fangliquanflq force-pushed the fix/acp-adapter-windows-bash-hang branch from b21dcec to 7d72a64 Compare July 30, 2026 06:48
Return a short stderr diagnostic from bounded_captured_run on timeout so
_bash_starts can cache it, and cover retained-stdin plus git-probe fail-open.
@fangliquanflq

Copy link
Copy Markdown
Author

Thanks for the salvage notes. Rebased onto current main and resolved the helper extraction conflict while retaining noninteractive_git_env().

Also followed up with timeout stderr diagnostics so _bash_starts can still cache probe details, plus Windows retained-stdin coverage for the stdin=DEVNULL contract. Branch is MERGEABLE on 450e2290c.

@OpenSpek

Copy link
Copy Markdown

Native Windows follow-up found one process-tree cleanup ordering gap that should be addressed before merge.

_kill_process_tree() currently calls proc.kill() before taskkill /T /F. Once the launcher exits, Windows can no longer reliably discover its descendants.

Controlled proof on Windows 10:

  • Current ordering: the timeout helper returned promptly, but left two matching processes alive in 3/3 trials.
  • Reordered cleanup (taskkill /T /F first, then proc.kill() as fallback): 0/3 trials left survivors.

The focused correction is two files:

  • hermes_cli/_subprocess_compat.py: run the Windows tree kill before the launcher fallback.
  • tests/test_windows_subprocess_no_window_flags.py: assert the cleanup order is taskkill -> kill -> bounded drain.

Verification on current main plus the PR contribution and correction:

  • timeout/retained-stdin tests: 7 passed
  • Git Bash probe tests: 3 passed
  • native three-trial descendant proof: 0 survivors
  • Ruff: passed

I recommend landing this correction before maintainer approval/merge. I have the exact local two-file diff available if useful.

Kill the process tree while the launcher PID is still discoverable; proc.kill() stays as fallback. Assert cleanup order taskkill -> kill -> bounded drain.
@fangliquanflq

fangliquanflq commented Jul 31, 2026 •

Copy link
Copy Markdown
Author

Thanks @OpenSpek - landed the cleanup ordering correction in e651925.

_kill_process_tree() now runs taskkill /T /F before proc.kill() on Windows so descendants stay discoverable; proc.kill() remains the fallback. The timeout unit test asserts taskkill -> kill -> bounded drain.

Focused tests: 7 passed for bounded/tree-kill/timeout paths.

@kaverjody

Copy link
Copy Markdown

Independent validation — Windows 10 + Git Bash + ACP (multica) ✓

We independently validated this fix on a real Windows ACP deployment, with confirmation from two separate agent instances.

Environment:

  • OS: Windows 10 Pro (25H2, NT 10.0 build 26200)
  • Shell: Git Bash / MSYS2
  • Hermes: v0.20.0 (commit 1be70d6)
  • ACP host: multica daemon spawning hermes.exe acp
  • Provider: deepseek (deepseek-v4-pro)
  • Terminal backend: local (bash)

Before (current v0.20.0):
The first terminal tool call of every ACP session hung forever. Stack matched the issue description exactly:

terminal_tool → _create_environment → LocalEnvironment.__init__
→ init_session → _find_bash → _bash_starts
→ subprocess.run(timeout=15) → _communicate (unbounded)

Daemon log showed "Creating new local environment for task default..." with NO further stderr output. Agent PID stayed alive, produced zero messages, killed by idle watchdog after 45 min (status=idle_watchdog, tool_in_flight=false).

After (this PR's fix backported to v0.20.0):
Two files patched:

  • hermes_cli/_subprocess_compat.py: added bounded_captured_run (Popen + tree-kill + 1s bounded drain + stdin=DEVNULL); rewrote _kill_git_process_tree → _kill_process_tree (taskkill before kill); rewrote bounded_git_probe as thin wrapper.
  • tools/environments/local.py: _bash_starts now uses bounded_captured_run instead of subprocess.run.

Results:

Metric Before After
First terminal call Hung 45 min → killed Succeeded
Total terminal calls across task 0 13
Task duration ~45 min (idle watchdog) ~1 min
End status aborted completed

The root cause matches the diagnosis: subprocess.run's post-timeout communicate() hangs because MSYS true/cat children retain captured pipe handles. stdin=DEVNULL prevents the ACP host's open stdin from stalling the bash probe, and the bounded drain avoids the reader-thread join deadlock. The taskkill /T /F before proc.kill() ordering is the correct approach on Windows.

Confirmed working on Windows 10 + real ACP host (multica). No regressions in non-ACP CLI usage. Thanks for the fix!

…ws-bash-hang

# Conflicts:
#	hermes_cli/_subprocess_compat.py
@fangliquanflq

Copy link
Copy Markdown
Author

Thank you, @kaverjody, for the thorough review. I have addressed the feedback, and the merge conflicts are now resolved.

@teknium1, could you please review and merge this PR when you have a chance? This is a serious Windows bug: a timed-out Git Bash startup probe can hang indefinitely while draining inherited pipes, blocking terminal and tool startup.

@lxman

lxman commented Aug 11, 2026

Copy link
Copy Markdown

This is the canonical fix for the bash probe hang — thanks for the thorough approach with bounded_captured_run. We had two complementary fixes you may want to be aware of:

Both are rebased onto current main as of 2cdb30a.

Resolve test_find_shell conflict: keep bounded_captured_run mocks and sys/Path imports; take main's windows_only markers.
@Squarelan

Copy link
Copy Markdown

Independent reproduction with before/after measurements — hermes-agent 0.20.5,
Windows 11 (10.0.26200), Python 3.11.15, Git for Windows, driving Hermes
through an ACP client.

Symptom

Every terminal call — including a bare printf 'x\n' — hung until the outer
concurrent-batch deadline and returned:

Error executing tool 'terminal': timed out after 420.0s

Three things made it clear the block is upstream of subprocess.Popen:

  • the terminal tool's own timeout never fired (it would have returned
    Command timed out after N seconds)
  • the ACP worker never spawned a single bash.exe
  • nothing at all in agent.log — no request id, no tool entry

That narrows it to _find_bash() → _bash_starts(), which runs at the top of
every _run_bash().

Smoking gun

An orphaned probe process, parent already dead, still alive 10+ minutes later:

38624  bash.exe  "C:\Program Files\Git\bin\..\usr\bin\bash.exe" \
                 --noprofile --norc -c "/usr/bin/true; /usr/bin/cat --version >/dev/null"
32784  <gone>

Minimal repro (no Hermes needed)

timeout=5, actual 60.12 s:

import subprocess, time
BASH = r"C:\Program Files\Git\bin\bash.exe"
t = time.time()
try:
    subprocess.run([BASH, "--noprofile", "--norc", "-c", "sleep 60"],
                   capture_output=True, text=True, timeout=5,
                   creationflags=0x08000000)
except subprocess.TimeoutExpired:
    print("%.2fs" % (time.time() - t))   # -> 60.12s

bin/bash.exe is a launcher shim; killing it orphans usr/bin/bash.exe, which
holds duplicates of the captured pipe handles, so run()'s post-timeout
communicate() blocks for exactly as long as the grandchild lives. If the
grandchild never exits, _bash_starts() never returns and every terminal call
is dead — silently.

Redirecting to a file instead of a pipe returns in 5.02 s, which matches
this PR's diagnosis. The note in the PR that the tree-kill must run before
proc.kill() is exactly what the orphan above demonstrates.

Confirming this PR's approach

Applied bounded_probe_run to the two remaining raw subprocess.run probes in
tools/environments/local.py — _bash_starts() (:927) and
_mandatory_aslr_enabled() (:854, the Get-ProcessMitigation call, same class
as the wmic scan in #87134):

before after
_bash_starts() when the probe times out 60.12 s 16.54 s (ok=False)
orphaned probe process left behind yes none (tree-kill)
normal terminal_tool() call 3.1 s 2.9 s

Would be good to see this land. The failure mode leaves no log line and no
spawned process, so from the user side it's indistinguishable from a hung
model call.

@fangliquanflq

Copy link
Copy Markdown
Author

Thanks for the independent Windows 11 reproduction and measurements. The findings are consistent with this PR's root cause: the Git-for-Windows launcher can exit while an MSYS descendant retains the captured pipe handles, so subprocess.run(..., timeout=...) can remain blocked in its post-timeout drain.

No additional code change is needed for the notified feedback:

  • Informational / already addressed: PR head 4e513b17f29cc4fdd7a6a91346a31bfb1fd4c4d3 routes _bash_starts() through the bounded probe helper, uses stdin=DEVNULL, tree-kills before the launcher fallback on Windows, and bounds the final drain. The PR's focused retained-stdin, timeout, and cleanup-order tests cover those contracts; the current head's Windows-only job and All required checks pass check both succeeded.
  • Out of scope for this PR: _mandatory_aslr_enabled() is a pre-existing diagnostic fallback on the base path and is only reached after all Bash candidates fail. Applying the newer shared bounded helper there is reasonable adjacent hardening, but it is not introduced by this PR and is not required to fix the reported _bash_starts() hang.

Your 60.12s-to-16.54s timeout measurement and zero-survivor result provide useful independent confirmation of the exact behavior this change targets. I did not rerun tests because this comment required no code change; the evidence above refers to the committed head and its recorded CI results.

teknium1 added a commit that referenced this pull request Sep 19, 2026
…3403)

_bash_starts used subprocess.run(timeout=15). On Windows run()'s post-timeout
cleanup is an unbounded communicate(): the probe's MSYS children (true/cat)
outlive the killed bash holding the captured pipe write ends, so the reader
join never returned and the ACP host wedged for minutes at the first terminal
tool (faulthandler dumps in the thread sit in _bash_starts → _communicate).

Route the probe through hermes_cli._subprocess_compat.bounded_probe_run — own
Popen (stdin=DEVNULL, hidden window), communicate(timeout), process-tree kill
and a 1 s bounded drain — so a probe that cannot finish fails fast and
_find_bash falls through to its last-resort candidate. A timeout is recorded as
the probe detail so the ASLR diagnostics still see it.

Supersedes #69083 (@fangliquanflq), whose bounded_captured_run design landed on
main as bounded_probe_run for the git probes (#68997); this is the surviving
_bash_starts call-site conversion from that PR.

Co-authored-by: fangliquanflq <[email protected]>
@teknium1

Copy link
Copy Markdown
Collaborator

Thanks @fangliquanflq — this landed on main via #115677 (b5b0a96): the same fix landed via a slimmer salvage; your PR is credited in its body. The Windows half was proven red→green on a real windows-latest runner (receipts in #115677's body). Closing this one in favour of the merged PR.

@teknium1 teknium1 closed this Sep 19, 2026
@alt-glitch alt-glitch added os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff and removed platform/windows labels Oct 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

backend/local Local shell execution comp/acp Agent Communication Protocol adapter comp/cli CLI entry point, hermes_cli/, setup wizard os/windows Native Windows behaviour or breakage: PowerShell install, Windows paths, cmd.exe, WSL-vs-native diff P2 Medium — degraded but workaround exists sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-platform-windows Sweeper risk: may break or behave differently on native Windows tool/terminal Terminal execution and process management type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Windows ACP adapter hangs when executing terminal tool

8 participants