fix(llm): tolerate non-UTF8 claude-cli output on Windows GBK systems (#1505)

On Windows where claude.cmd emits GBK/cp936 bytes, the claude-cli subprocess
decoding raised UnicodeDecodeError and crashed extraction. Both claude-cli
subprocess.run sites (_call_claude_cli and the claude-cli branch of _call_llm) now
pass errors="replace", so incidental non-UTF8 console chatter decodes to
replacement chars instead of crashing — the structured JSON payload (ASCII/UTF-8
on stdout) is unaffected. capture_output=True means the single errors= covers both
stdout and stderr.

Ported from PR #1507 by @nuthalapativarun (dropped an unrelated .gitignore change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
nuthalapativarun
2026-06-28 10:32:57 +01:00
committed by safishamsi
co-authored by Claude Opus 4.8
parent 1f3f1c1ca6
commit 0e8d92cf5f
2 changed files with 54 additions and 0 deletions
+2
View File
@@ -1160,6 +1160,7 @@ def _call_claude_cli(user_message: str, max_tokens: int = 8192, *, deep_mode: bo
capture_output=True,
text=True,
encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252
errors="replace", # Tolerate non-UTF-8 bytes (e.g. GBK/cp936 from claude.cmd on Chinese Windows)
timeout=_resolve_api_timeout(),
check=False,
**_no_window_kwargs(),
@@ -1913,6 +1914,7 @@ def _call_llm(
capture_output=True,
text=True,
encoding="utf-8", # Force UTF-8 — prevents UnicodeEncodeError on Windows cp1252
errors="replace", # Tolerate non-UTF-8 bytes (e.g. GBK/cp936 from claude.cmd on Chinese Windows)
timeout=_resolve_api_timeout(),
check=False,
**_no_window_kwargs(),
+52
View File
@@ -931,3 +931,55 @@ def test_base_url_defaults_without_env(backend, default):
env=env, capture_output=True, text=True, check=True,
)
assert out.stdout.strip() == default
# ---------------------------------------------------------------------------
# #1505: claude-cli subprocess.run must use errors="replace" so non-UTF-8
# bytes from claude.cmd on Chinese Windows (GBK/cp936) don't crash the reader
# thread.
# ---------------------------------------------------------------------------
import json as _json
def _make_cli_envelope(result_text: str) -> str:
"""Return a minimal claude -p --output-format json envelope."""
return _json.dumps({"type": "result", "result": result_text, "usage": {}, "modelUsage": {}})
def test_call_claude_cli_passes_errors_replace_to_subprocess():
"""subprocess.run must be called with errors='replace' so non-UTF-8 output
bytes (e.g. GBK from claude.cmd on Chinese Windows) are tolerated instead
of crashing the reader thread with UnicodeDecodeError (#1505)."""
from unittest.mock import patch, MagicMock
valid_envelope = _make_cli_envelope('{"nodes":[],"edges":[],"hyperedges":[]}')
mock_proc = MagicMock()
mock_proc.returncode = 0
mock_proc.stdout = valid_envelope
mock_proc.stderr = ""
with patch("platform.system", return_value="Linux"), \
patch("shutil.which", return_value="/usr/bin/claude"), \
patch("subprocess.run", return_value=mock_proc) as mock_run:
llm._call_claude_cli("test prompt")
assert mock_run.call_args.kwargs.get("errors") == "replace", \
"subprocess.run missing errors='replace' — non-UTF-8 bytes will crash the reader thread"
def test_call_claude_cli_tolerates_non_utf8_in_stderr():
"""When errors='replace' is set, non-UTF-8 bytes in stderr produce replacement
chars instead of UnicodeDecodeError, allowing the error path to report cleanly."""
from unittest.mock import patch, MagicMock
mock_proc = MagicMock()
mock_proc.returncode = 1
mock_proc.stdout = ""
mock_proc.stderr = "GBK error: " # replacement chars after decode
with patch("platform.system", return_value="Linux"), \
patch("shutil.which", return_value="/usr/bin/claude"), \
patch("subprocess.run", return_value=mock_proc):
with pytest.raises(RuntimeError, match="claude -p exited 1"):
llm._call_claude_cli("test prompt")