mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
fix(cli): suppress CPR on POSIX local TTYs under load
Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent load. Suppress CPR on non-Windows platforms (layout hint only); keep native Windows on prompt_toolkit's default pending native coverage. Wire selection through _select_classic_cli_pt_output.
This commit is contained in:
parent
7651764ce6
commit
3f820a1c7c
1 changed files with 41 additions and 49 deletions
90
cli.py
90
cli.py
|
|
@ -3270,28 +3270,28 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _terminal_may_leak_cpr() -> bool:
|
||||
"""Detect terminals where CPR (ESC[6n) replies are likely to leak.
|
||||
def _terminal_may_leak_cpr(*, platform: str | None = None) -> bool:
|
||||
"""Whether classic CLI should suppress prompt_toolkit CPR (ESC[6n) queries.
|
||||
|
||||
The CPR leak in #13870 is environment-specific: it shows up over SSH +
|
||||
cloudflared/mux tunnels and slow PTYs, where the terminal's
|
||||
``ESC[<row>;<col>R`` reply round-trips slowly enough to race past the input
|
||||
parser and land in the display as raw ``20;1R`` text (and the pending-CPR
|
||||
future can stall the renderer, freezing the prompt). On a local terminal the
|
||||
reply returns instantly and cleanly, so CPR works fine and there is nothing
|
||||
to fix — we leave prompt_toolkit's default behavior untouched there.
|
||||
Delayed CPR replies (``ESC[<row>;<col>R`` / visible ``^[[<row>;<col>R``)
|
||||
leak into the status line and can freeze input when the reply is slow
|
||||
(#13870 on SSH/slow PTYs). The same race hits **local POSIX** TTYs under
|
||||
heavy subagent / status-line load — deterministic delayed-CPR PTY harness
|
||||
in ``tests/cli/test_cpr_local_leak.py``.
|
||||
|
||||
We only suppress CPR on a remote/tunneled link (SSH env vars) or when the
|
||||
user has explicitly opted out via prompt_toolkit's own ``PROMPT_TOOLKIT_NO_CPR``
|
||||
escape hatch. Keeping this narrow (not the broader WSL/Ghostty/Windows set
|
||||
that ``_preserve_ctrl_enter_newline`` keys on) means the only behavior change
|
||||
lands exactly where the bug reproduces.
|
||||
Policy:
|
||||
- ``PROMPT_TOOLKIT_NO_CPR=1`` → always suppress
|
||||
- native Windows (``win32``) → keep prompt_toolkit's default for now
|
||||
(no native-Windows Application coverage yet); still honor NO_CPR
|
||||
- all other platforms → suppress (CPR is only a layout hint; heuristic
|
||||
height is enough). SSH env is no longer required to trigger this.
|
||||
"""
|
||||
if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1":
|
||||
return True
|
||||
if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")):
|
||||
return True
|
||||
return False
|
||||
plat = sys.platform if platform is None else platform
|
||||
if plat == "win32":
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _build_cpr_disabled_output(stdout):
|
||||
|
|
@ -3299,23 +3299,14 @@ def _build_cpr_disabled_output(stdout):
|
|||
|
||||
prompt_toolkit's renderer sends ``ESC[6n`` (Device Status Report) to learn
|
||||
the cursor row before painting in non-fullscreen mode; the terminal replies
|
||||
``ESC[<row>;<col>R``. Over SSH + cloudflared/mux tunnels and some slow PTYs
|
||||
these replies race past the input parser and land in the display as raw text
|
||||
like ``20;1R21;1R``, and the pending-CPR future can stall the renderer so the
|
||||
prompt appears frozen after the agent's final answer (see #13870).
|
||||
``ESC[<row>;<col>R``. When that reply is delayed it races into the display
|
||||
as raw ``^[[39;1R`` and can stall the renderer's pending-CPR future
|
||||
(#13870; also local POSIX under heavy subagent load).
|
||||
|
||||
Constructing the output with ``enable_cpr=False`` makes the renderer mark CPR
|
||||
``NOT_SUPPORTED`` up front, so ``ESC[6n`` is never sent and no CPR response
|
||||
can leak. This is the root-cause counterpart to the input-side scrubbing in
|
||||
``_strip_leaked_terminal_responses`` — that cleans leaks after the fact; this
|
||||
stops them at the source. The UI is otherwise identical (prompt_toolkit uses
|
||||
its heuristic available-height fallback, which it already relies on whenever a
|
||||
terminal doesn't answer CPR).
|
||||
|
||||
This is only invoked on terminals flagged by ``_terminal_may_leak_cpr()`` —
|
||||
CPR is a layout hint, not a speed optimization, and it works fine locally, so
|
||||
we leave the upstream default in place on local terminals and only suppress it
|
||||
where the leak actually reproduces.
|
||||
Constructing the output with ``enable_cpr=False`` marks CPR
|
||||
``NOT_SUPPORTED`` so ``ESC[6n`` is never sent. prompt_toolkit then uses its
|
||||
heuristic available-height fallback. Input-side
|
||||
``_strip_leaked_terminal_responses`` remains belt-and-suspenders.
|
||||
|
||||
Note: ``Vt100_Output.from_pty()`` does NOT expose ``enable_cpr`` in
|
||||
prompt_toolkit 3.x, so we reproduce its ``get_size`` setup and call the
|
||||
|
|
@ -3341,6 +3332,18 @@ def _build_cpr_disabled_output(stdout):
|
|||
return None
|
||||
|
||||
|
||||
def _select_classic_cli_pt_output(stdout):
|
||||
"""Select prompt_toolkit Output for classic-CLI Application construction.
|
||||
|
||||
Returns a CPR-disabled ``Vt100_Output`` when ``_terminal_may_leak_cpr()``
|
||||
is true, otherwise ``None`` so Application keeps prompt_toolkit's default
|
||||
output (Windows preserve-default path).
|
||||
"""
|
||||
if not _terminal_may_leak_cpr():
|
||||
return None
|
||||
return _build_cpr_disabled_output(stdout)
|
||||
|
||||
|
||||
def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]:
|
||||
"""Strip leaked terminal control-response sequences from user input.
|
||||
|
||||
|
|
@ -14859,22 +14862,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin):
|
|||
}
|
||||
style = PTStyle.from_dict(self._build_tui_style_dict())
|
||||
|
||||
# Disable CPR (Cursor Position Report) at the source so prompt_toolkit
|
||||
# never sends ESC[6n cursor-position queries — but only on terminals
|
||||
# where the reply is likely to leak. Over SSH/cloudflared tunnels and
|
||||
# slow PTYs the CPR replies (ESC[<row>;<col>R) leak into the display as
|
||||
# raw "20;1R21;1R" text and can stall the renderer's pending-CPR future,
|
||||
# freezing the prompt after the agent's final answer (#13870). CPR is a
|
||||
# layout hint, not a speed optimization, and it works fine locally, so we
|
||||
# leave prompt_toolkit's default untouched on local terminals and only
|
||||
# suppress it where the bug reproduces. None (local, or build failure)
|
||||
# falls back to the default output; the input-side scrubbing in
|
||||
# _strip_leaked_terminal_responses still guards against any leaks.
|
||||
_cpr_disabled_output = (
|
||||
_build_cpr_disabled_output(sys.stdout)
|
||||
if _terminal_may_leak_cpr()
|
||||
else None
|
||||
)
|
||||
# Select CPR-disabled output when _terminal_may_leak_cpr() says so
|
||||
# (POSIX local + SSH; Windows keeps PT default — see helper docs).
|
||||
# None falls back to prompt_toolkit's default output; input scrubbing
|
||||
# in _strip_leaked_terminal_responses still guards residual leaks.
|
||||
_cpr_disabled_output = _select_classic_cli_pt_output(sys.stdout)
|
||||
|
||||
# Create the application
|
||||
app = Application(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue