fix: disable Rich Live transient mode on Windows to prevent PS 5.1 hang (#2938)

* fix: disable Rich Live transient mode on Windows to prevent PS 5.1 hang

PowerShell 5.1's legacy console host does not reliably support VT escape
sequences. Rich's Live(transient=True) attempts cursor restoration on
context exit, which hangs indefinitely on that console.

Set transient=False when sys.platform == 'win32' in both init.py (progress
tracker) and _console.py (select_with_arrows). The only cosmetic effect is
that progress output remains visible after completion on Windows.

Fixes #2927

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: address review feedback on test quality

- Use captured['transient'] instead of .get() for clearer KeyError on failure
- Source guards now assert both the platform check AND transient=_transient usage
- Remove unused imports (MagicMock retained as it's used, removed pytest)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: use regex in source guards for resilience to formatting changes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* test: use single DOTALL regex to verify assignment flows into Live()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* fix: skip duplicate tracker print on Windows when transient=False

When transient is False, Rich leaves the Live output on screen. The
subsequent console.print(tracker.render()) would duplicate it. Gate
it behind _transient so Windows users see the tracker exactly once.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Manfred Riem <mnriem@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Manfred Riem
2026-06-17 06:48:42 -05:00
committed by GitHub
parent ae23a84677
commit 75aee19c6e
3 changed files with 100 additions and 3 deletions

View File

@@ -7,6 +7,7 @@ layer, not out of it, to avoid circular imports.
""" """
from __future__ import annotations from __future__ import annotations
import sys
from collections.abc import Callable from collections.abc import Callable
import readchar import readchar
@@ -192,7 +193,8 @@ def select_with_arrows(
def run_selection_loop(): def run_selection_loop():
nonlocal selected_key, selected_index nonlocal selected_key, selected_index
with Live(create_selection_panel(), console=console, transient=True, auto_refresh=False) as live: _transient = sys.platform != "win32"
with Live(create_selection_panel(), console=console, transient=_transient, auto_refresh=False) as live:
while True: while True:
try: try:
key = get_key() key = get_key()

View File

@@ -381,8 +381,12 @@ def register(app: typer.Typer) -> None:
]: ]:
tracker.add(key, label) tracker.add(key, label)
# Disable transient mode on Windows: PowerShell 5.1's legacy console
# hangs when Rich tries to restore cursor state via VT escape sequences.
_transient = sys.platform != "win32"
with Live( with Live(
tracker.render(), console=console, refresh_per_second=8, transient=True tracker.render(), console=console, refresh_per_second=8, transient=_transient
) as live: ) as live:
tracker.attach_refresh(lambda: live.update(tracker.render())) tracker.attach_refresh(lambda: live.update(tracker.render()))
try: try:
@@ -652,7 +656,8 @@ def register(app: typer.Typer) -> None:
finally: finally:
pass pass
console.print(tracker.render()) if _transient:
console.print(tracker.render())
console.print("\n[bold green]Project ready.[/bold green]") console.print("\n[bold green]Project ready.[/bold green]")
agent_config = AGENT_CONFIG.get(selected_ai) agent_config = AGENT_CONFIG.get(selected_ai)

View File

@@ -0,0 +1,90 @@
"""Tests for Rich Live transient=False on Windows (GitHub issue #2927).
PowerShell 5.1's legacy console host does not support VT escape sequences
reliably. Rich's ``Live(transient=True)`` attempts cursor restoration on
exit, which hangs indefinitely on that console. The fix disables transient
mode when ``sys.platform == "win32"``.
These tests patch ``sys.platform`` and intercept the ``Live`` constructor
to verify the correct ``transient`` value reaches Rich.
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
# ---------------------------------------------------------------------------
# _console.py — Live in the select_with_arrows helper
# ---------------------------------------------------------------------------
def _invoke_select_with_arrows(platform: str) -> bool:
"""Patch sys.platform and Live, invoke select_with_arrows, return transient kwarg."""
captured = {}
mock_live_instance = MagicMock()
mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance)
mock_live_instance.__exit__ = MagicMock(return_value=False)
def fake_live(*args, **kwargs):
captured.update(kwargs)
return mock_live_instance
# Patch readchar so the loop immediately returns "enter"
import readchar
with (
patch("sys.platform", platform),
patch("specify_cli._console.Live", side_effect=fake_live),
patch("specify_cli._console.readchar.readkey", return_value=readchar.key.ENTER),
):
from specify_cli._console import select_with_arrows
select_with_arrows({"a": "Option A", "b": "Option B"}, "Pick one", "a")
return captured["transient"]
class TestSelectWithArrowsLiveTransient:
"""Verify that select_with_arrows passes transient=False on Windows."""
def test_transient_false_on_windows(self):
assert _invoke_select_with_arrows("win32") is False
def test_transient_true_on_linux(self):
assert _invoke_select_with_arrows("linux") is True
def test_transient_true_on_macos(self):
assert _invoke_select_with_arrows("darwin") is True
# ---------------------------------------------------------------------------
# init.py — verify source contains the platform guard (regression check)
# ---------------------------------------------------------------------------
class TestSourceContainsPlatformGuard:
"""Ensure the platform guard feeds into the Live() transient kwarg."""
# Single DOTALL regex: _transient assigned from win32 check, then used in Live()
_GUARD_RE = r"_transient\s*=\s*sys\.platform\s*!=\s*['\"]win32['\"].*Live\(.*transient\s*=\s*_transient"
def test_init_has_win32_guard(self):
"""init.py must assign _transient from platform check and pass it to Live."""
import re
init_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "commands" / "init.py"
content = init_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
def test_console_has_win32_guard(self):
"""_console.py must assign _transient from platform check and pass it to Live."""
import re
console_src = Path(__file__).resolve().parent.parent / "src" / "specify_cli" / "_console.py"
content = console_src.read_text(encoding="utf-8")
assert re.search(self._GUARD_RE, content, re.DOTALL)
assert re.search(r"transient\s*=\s*_transient", content)
assert "transient=_transient" in content