mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-29 06:31:32 +00:00
fix(tui): suppress mouse-residue leaks during Python launcher startup (#31213)
* fix(tui): suppress mouse-residue leaks during Python launcher startup `hermes --tui …` spends ~100–300ms inside the Python launcher (lazy imports, arg parsing, session resolution) before exec'ing the Node TUI binary. During that window stdin is still in cooked + echo mode. If a prior session left DEC mouse tracking asserted (or the user spammed mouse movement while the previous session was opening), the terminal keeps emitting `\\x1b[<…M` SGR motion reports that get echoed straight back into the user's shell scrollback as literal `^[[<…M` text and sit there above the TUI banner until the next clear. The Node side already calls `resetTerminalModes()` in `entry.tsx`, but by then the race is already lost — the bytes echoed during the Python warmup window were committed to the scrollback before Node started. Fix: write the mouse-tracking disable sequence at the very top of `hermes_cli.main`, before every heavy import. The terminal stops emitting motion events as soon as the bytes hit the wire (one TTY round-trip), shrinking the race window from hundreds of milliseconds to a few. `HERMES_TUI_NO_EARLY_DISABLE=1` opts out for diagnostics. * test(tui): drop dead _reload_main, hoist import out of patch context Addresses Copilot review on PR #31213. The tests used to import `hermes_cli.main` inside the `patch("os.write")` context, which Copilot pointed out is order-dependent: if the module is already loaded (e.g. imported by a prior test in the same process), the import is a no-op and the patch only sees the explicit `_suppress_mouse_residue_early()` call. Either way the assertion can flake when run alongside other tests. Move the import to module scope — every subprocess gets a fresh `hermes_cli.main`, whose module-level invocation is a no-op under pytest argv. Tests then exercise `_suppress_mouse_residue_early()` directly inside their own patch context. Also drop the unused `_reload_main` helper. * fix(tui): skip early mouse-disable when stdout is not a TTY Addresses Copilot review on PR #31213. `hermes --tui … >log` or CI capture pipes fd 1 away from the terminal. The disable bytes can't reach the terminal in that case but would still get written into the log file as raw CSI sequences. Guard with `os.isatty(1)` inside the existing `try/except OSError` block so the 'never break startup' contract holds. * docs(tui): rephrase 'raw cooked mode' as 'cooked + echo mode' Copilot review nit on PR #31213 — the original wording was self- contradictory. Pre-TUI stdin state is cooked + echo (kernel TTY discipline still owns the line buffer and echoes input back). The TUI switches it to raw mode later when Ink mounts.
This commit is contained in:
parent
0927fb5584
commit
912e6e2274
2 changed files with 125 additions and 0 deletions
|
|
@ -65,6 +65,39 @@ import os
|
|||
import sys
|
||||
|
||||
|
||||
# Mouse-tracking residue suppression — runs BEFORE every other import on the
|
||||
# TUI hot path so the terminal stops emitting SGR/X10 mouse reports while the
|
||||
# Python launcher is still doing imports (≈100–300ms in cooked + echo mode,
|
||||
# before the Node TUI takes stdin into raw mode). During that window any
|
||||
# incoming bytes are echoed straight back to the user's shell scrollback as
|
||||
# ``^[[<…M`` text. The TUI itself runs `resetTerminalModes()` again in
|
||||
# `entry.tsx`; this is just the earlier cousin. ``HERMES_TUI_NO_EARLY_DISABLE``
|
||||
# escapes the behaviour for diagnostics.
|
||||
def _suppress_mouse_residue_early() -> None:
|
||||
if os.environ.get("HERMES_TUI_NO_EARLY_DISABLE") == "1":
|
||||
return
|
||||
if not (os.environ.get("HERMES_TUI") == "1" or "--tui" in sys.argv[1:]):
|
||||
return
|
||||
try:
|
||||
# Skip when stdout is redirected (`hermes --tui … >log`, CI capture):
|
||||
# the bytes can't reach the terminal anyway and would just pollute
|
||||
# the log with raw CSI.
|
||||
if not os.isatty(1):
|
||||
return
|
||||
# Disable every mouse-tracking variant we know about. Idempotent and
|
||||
# safe to send even when no tracking is currently asserted.
|
||||
os.write(
|
||||
1,
|
||||
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
|
||||
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l",
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
|
||||
def _is_termux_startup_environment_fast() -> bool:
|
||||
"""Tiny Termux check for pre-import startup shortcuts."""
|
||||
prefix = os.environ.get("PREFIX", "")
|
||||
|
|
|
|||
92
tests/hermes_cli/test_tui_mouse_residue_suppression.py
Normal file
92
tests/hermes_cli/test_tui_mouse_residue_suppression.py
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
"""Tests for the TUI-hot-path mouse-residue suppression.
|
||||
|
||||
The Python launcher (`hermes --tui …`) has a ~100–300ms cold-start window
|
||||
where stdin is still in cooked + echo mode. If a previous Hermes session
|
||||
left DEC mouse-tracking asserted, any mouse motion during that window
|
||||
echoes literal ``^[[<…M`` text into the user's scrollback.
|
||||
|
||||
`_suppress_mouse_residue_early()` writes the disable sequence to stdout
|
||||
before the heavy imports so the terminal stops emitting events ASAP.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
# Importing the module triggers `_suppress_mouse_residue_early()` at module
|
||||
# scope. Under the test runner argv (`pytest …`) it's a no-op, but we import
|
||||
# at file scope so individual tests don't race the import side-effect with
|
||||
# their `patch("os.write")` context.
|
||||
from hermes_cli.main import _suppress_mouse_residue_early
|
||||
|
||||
EXPECTED = (
|
||||
b"\x1b[?1003l\x1b[?1002l\x1b[?1001l\x1b[?1000l\x1b[?9l"
|
||||
b"\x1b[?1006l\x1b[?1005l\x1b[?1015l\x1b[?1016l\x1b[?2029l"
|
||||
)
|
||||
|
||||
|
||||
class TestEarlyMouseDisable:
|
||||
def test_writes_disable_sequence_when_tui_flag_in_argv(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui", "-c", "abc"])
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
|
||||
|
||||
with patch("os.isatty", return_value=True), patch("os.write") as mock_write:
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
mock_write.assert_called_once_with(1, EXPECTED)
|
||||
|
||||
def test_writes_disable_sequence_when_hermes_tui_env_set(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["hermes"])
|
||||
monkeypatch.setenv("HERMES_TUI", "1")
|
||||
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
|
||||
|
||||
with patch("os.isatty", return_value=True), patch("os.write") as mock_write:
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
mock_write.assert_called_once_with(1, EXPECTED)
|
||||
|
||||
def test_no_op_on_non_tui_invocation(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--version"])
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
|
||||
|
||||
with patch("os.write") as mock_write:
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
mock_write.assert_not_called()
|
||||
|
||||
def test_respects_diagnostic_escape_hatch(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.setenv("HERMES_TUI_NO_EARLY_DISABLE", "1")
|
||||
|
||||
with patch("os.write") as mock_write:
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
mock_write.assert_not_called()
|
||||
|
||||
def test_skips_when_stdout_is_not_a_tty(self, monkeypatch):
|
||||
# `hermes --tui … >log` or CI capture: pipe is fd 1, not a TTY. The
|
||||
# bytes can't reach a terminal and would just pollute the log.
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
|
||||
|
||||
with patch("os.isatty", return_value=False), patch("os.write") as mock_write:
|
||||
_suppress_mouse_residue_early()
|
||||
|
||||
mock_write.assert_not_called()
|
||||
|
||||
def test_oserror_is_swallowed(self, monkeypatch):
|
||||
monkeypatch.setattr(sys, "argv", ["hermes", "--tui"])
|
||||
monkeypatch.delenv("HERMES_TUI", raising=False)
|
||||
monkeypatch.delenv("HERMES_TUI_NO_EARLY_DISABLE", raising=False)
|
||||
|
||||
def boom(*_a, **_k):
|
||||
raise OSError("stdout closed")
|
||||
|
||||
with patch("os.isatty", return_value=True), patch("os.write", side_effect=boom):
|
||||
# Must not propagate — startup hot path can never break.
|
||||
_suppress_mouse_residue_early()
|
||||
Loading…
Add table
Add a link
Reference in a new issue