diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ba526354a3..c56fc1e39e 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5274,12 +5274,19 @@ def _warn_stale_dashboard_processes() -> None: try: if sys.platform == "win32": + # wmic emits text in the system code page (e.g. cp936 on zh-CN + # locales), not UTF-8. Without an explicit encoding, Python's + # default UTF-8 decoder crashes the subprocess reader thread + # with UnicodeDecodeError, leaving result.stdout=None and the + # subsequent .split() call crashing `hermes update` with an + # AttributeError on Windows non-UTF-8 locales (#17049). result = subprocess.run( ["wmic", "process", "get", "ProcessId,CommandLine", "/FORMAT:LIST"], capture_output=True, text=True, timeout=10, + encoding="utf-8", errors="ignore", ) - if result.returncode != 0: + if result.returncode != 0 or result.stdout is None: return current_cmd = "" for line in result.stdout.split("\n"): diff --git a/tests/hermes_cli/test_update_stale_dashboard.py b/tests/hermes_cli/test_update_stale_dashboard.py index 20c5eee98c..b36f9e5a49 100644 --- a/tests/hermes_cli/test_update_stale_dashboard.py +++ b/tests/hermes_cli/test_update_stale_dashboard.py @@ -175,3 +175,57 @@ class TestWarnStaleDashboardProcesses: output = capsys.readouterr().out assert "PID 99999" not in output assert "PID 12345" in output + + +class TestWindowsWmicEncoding: + """Regression tests for #17049 — the Windows wmic branch must not crash + `hermes update` on non-UTF-8 system locales (e.g. cp936 on zh-CN). + """ + + def test_wmic_invoked_with_utf8_ignore_errors(self): + """The wmic subprocess.run call must pass encoding='utf-8' and + errors='ignore' so the subprocess reader thread cannot raise + UnicodeDecodeError on non-UTF-8 wmic output.""" + with patch("hermes_cli.main.sys") as mock_sys, \ + patch("subprocess.run") as mock_run: + mock_sys.platform = "win32" + # Provide a minimal valid wmic /FORMAT:LIST response. + mock_run.return_value = MagicMock( + returncode=0, + stdout=( + "CommandLine=python -m hermes_cli.main dashboard\n" + "ProcessId=12345\n" + ), + stderr="", + ) + _warn_stale_dashboard_processes() + + assert mock_run.called, "subprocess.run was not invoked" + kwargs = mock_run.call_args.kwargs + assert kwargs.get("encoding") == "utf-8", ( + "encoding kwarg must be 'utf-8' so wmic output is decoded " + "deterministically rather than via the implicit reader-thread " + "default that crashes on non-UTF-8 locales (#17049)." + ) + assert kwargs.get("errors") == "ignore", ( + "errors kwarg must be 'ignore' so undecodable bytes don't take " + "down the reader thread (#17049)." + ) + + def test_wmic_returns_none_stdout_does_not_crash(self, capsys): + """If subprocess.run returns successfully but stdout is None — which + is what Python 3.11 leaves behind when the reader thread silently + crashed on UnicodeDecodeError before this fix landed — the warning + must short-circuit instead of raising AttributeError on + ``None.split('\\n')`` and aborting `hermes update` (#17049).""" + with patch("hermes_cli.main.sys") as mock_sys, \ + patch("subprocess.run") as mock_run: + mock_sys.platform = "win32" + mock_run.return_value = MagicMock( + returncode=0, stdout=None, stderr="" + ) + # Must not raise. + _warn_stale_dashboard_processes() + + output = capsys.readouterr().out + assert "dashboard process" not in output