fix(windows): enable VT processing so console output renders colours

`hermes setup` launched by install.ps1 on PowerShell 5.1/conhost printed
`[35m`/`[0m` literally. hermes_cli.colors and the skins emit raw SGR codes
whenever stdout is a TTY, but no Hermes entry point ever set
ENABLE_VIRTUAL_TERMINAL_PROCESSING (origin/main didn't either; only
pm/cli.py did, for its own progress line), and shells hand native children
a console with VT off.

hermes_bootstrap now opts the stdout/stderr console in at import, before
anything prints and before the venv relaunch (the mode lives on the console
buffer, so the child inherits it). Non-console handles are left alone; a
console that refuses VT gets NO_COLOR so it shows plain text instead of
escape garbage. ctypes only: colorama is merely transitive.
This commit is contained in:
ethernet
2026-09-24 14:13:05 -04:00
parent dec282c658
commit cfeffe851d
2 changed files with 86 additions and 1 deletions

View File

@@ -1,4 +1,4 @@
"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio, import-path
"""Process bootstrap for Hermes entry points: Windows UTF-8 stdio and ANSI console, import-path
hardening, durable lazy-install target, and dual-stack (Happy Eyeballs) connects.
Windows binds stdio to the console code page (cp1252), so ``print("café")`` raises
@@ -253,6 +253,47 @@ def apply_windows_utf8_bootstrap() -> bool:
return True
_ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
def enable_windows_vt(streams=None) -> bool:
"""Opt the console behind stdout/stderr in to ANSI escape processing.
``hermes_cli.colors`` and the skins emit raw SGR codes whenever stdout is a TTY. A
conhost console (PowerShell 5.1, cmd.exe, the installer's ``hermes setup``) prints
them as ``←[35m`` until the output handle has ENABLE_VIRTUAL_TERMINAL_PROCESSING, and
shells hand native children a console with it off. The mode belongs to the console
buffer, so setting it here also covers the relaunched child. Handles that are not a
console (pipes, files, NUL, a windowless pythonw) fail GetConsoleMode and are left
alone. A console that refuses VT (pre-Windows 10) gets NO_COLOR instead, which
``should_use_color`` and rich honour, so it shows plain text rather than garbage.
Returns False only in that fallback case.
"""
if not _IS_WINDOWS:
return True
import ctypes
import msvcrt
from ctypes import wintypes
kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
enabled = True
for stream in (sys.stdout, sys.stderr) if streams is None else streams:
try:
handle = msvcrt.get_osfhandle(stream.fileno())
except (AttributeError, OSError, ValueError):
continue # no fd (None under pythonw, StringIO in embedders)
mode = wintypes.DWORD()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
continue
if mode.value & _ENABLE_VIRTUAL_TERMINAL_PROCESSING:
continue
if not kernel32.SetConsoleMode(handle, mode.value | _ENABLE_VIRTUAL_TERMINAL_PROCESSING):
enabled = False
if not enabled:
os.environ.setdefault("NO_COLOR", "1")
return enabled
def suppress_platform_ver_console() -> None:
"""Stub ``platform._syscmd_ver`` on Windows — decode-crash + console-flash guard.
@@ -432,6 +473,7 @@ def export_scratch_tmp_env() -> None:
# the very top of their module, before importing anything else. The
# import side effect does the right thing.
apply_windows_utf8_bootstrap()
enable_windows_vt()
suppress_platform_ver_console()
install_never_free_environ()

View File

@@ -395,6 +395,49 @@ class TestHardenImportPath:
class TestEnableWindowsVt:
"""Hermes prints raw SGR codes; a conhost console renders them only with VT on."""
@pytest.mark.platforms("windows")
def test_turns_vt_on_for_a_console_that_has_it_off(self):
# A fresh console via CREATE_NEW_CONSOLE is the installer's situation:
# a real conhost whose output handle starts without VT processing.
script = textwrap.dedent("""
import ctypes, msvcrt, sys
from ctypes import wintypes
sys.path.insert(0, sys.argv[1])
import hermes_bootstrap
kernel32 = ctypes.WinDLL("kernel32")
handle = msvcrt.get_osfhandle(sys.stdout.fileno())
mode = wintypes.DWORD()
if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)):
sys.exit(4)
kernel32.SetConsoleMode(handle, mode.value & ~0x0004)
ok = hermes_bootstrap.enable_windows_vt()
kernel32.GetConsoleMode(handle, ctypes.byref(mode))
sys.exit(0 if ok and mode.value & 0x0004 else 3)
""").strip()
root = str(Path(__file__).resolve().parents[1])
result = subprocess.run(
[sys.executable, "-c", script, root],
creationflags=subprocess.CREATE_NEW_CONSOLE,
timeout=120,
)
if result.returncode == 4:
pytest.skip("this session cannot create a console")
assert result.returncode == 0
@pytest.mark.platforms("windows")
def test_leaves_non_console_handles_and_colour_alone(self, tmp_path, monkeypatch):
# Redirected output must neither fail nor flip Hermes to NO_COLOR.
monkeypatch.delenv("NO_COLOR", raising=False)
import hermes_bootstrap
with open(tmp_path / "out.txt", "w", encoding="utf-8") as stream:
assert hermes_bootstrap.enable_windows_vt([stream]) is True
assert "NO_COLOR" not in os.environ
class TestSuppressPlatformVerConsole:
"""suppress_platform_ver_console: stub applied on Windows, no-op on POSIX."""