mirror of
https://github.com/github/spec-kit.git
synced 2026-07-03 12:28:06 +08:00
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:
@@ -7,6 +7,7 @@ layer, not out of it, to avoid circular imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
import readchar
|
||||
@@ -192,7 +193,8 @@ def select_with_arrows(
|
||||
|
||||
def run_selection_loop():
|
||||
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:
|
||||
try:
|
||||
key = get_key()
|
||||
|
||||
@@ -381,8 +381,12 @@ def register(app: typer.Typer) -> None:
|
||||
]:
|
||||
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(
|
||||
tracker.render(), console=console, refresh_per_second=8, transient=True
|
||||
tracker.render(), console=console, refresh_per_second=8, transient=_transient
|
||||
) as live:
|
||||
tracker.attach_refresh(lambda: live.update(tracker.render()))
|
||||
try:
|
||||
@@ -652,6 +656,7 @@ def register(app: typer.Typer) -> None:
|
||||
finally:
|
||||
pass
|
||||
|
||||
if _transient:
|
||||
console.print(tracker.render())
|
||||
console.print("\n[bold green]Project ready.[/bold green]")
|
||||
|
||||
|
||||
90
tests/test_live_transient_windows.py
Normal file
90
tests/test_live_transient_windows.py
Normal 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
|
||||
Reference in New Issue
Block a user