From e5ff5957ff84d38664f31fe375ecc5681e762e2a Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Sat, 23 May 2026 17:40:44 +0800 Subject: [PATCH] fix: tolerate non-utf8 worker output --- src/kimi_cli/web/runner/process.py | 14 ++++++---- tests/web/test_session_error_recovery.py | 33 ++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/kimi_cli/web/runner/process.py b/src/kimi_cli/web/runner/process.py index 16f60a895..d213d317c 100644 --- a/src/kimi_cli/web/runner/process.py +++ b/src/kimi_cli/web/runner/process.py @@ -51,6 +51,10 @@ JSONRPCOutMessageAdapter = TypeAdapter[JSONRPCOutMessage](JSONRPCOutMessage) +def _decode_worker_output(data: bytes) -> str: + return data.decode("utf-8", errors="replace") + + class SessionProcess: """Manages a single session's KimiCLI subprocess. @@ -306,6 +310,7 @@ async def _read_loop(self) -> None: stderr = await self._process.stderr.read() if not stderr: stderr = b"No stderr" + stderr_text = _decode_worker_output(stderr) # Clear in-flight IDs before broadcasting so that # is_busy is already False when the frontend reacts # to the error and sends a new prompt. @@ -315,24 +320,23 @@ async def _read_loop(self) -> None: id=str(uuid4()), error=JSONRPCErrorObject( code=self._process.returncode or -1, - message=stderr.decode("utf-8"), + message=stderr_text, ), ).model_dump_json() ) logger.warning( - f"Process exited with {self._process.returncode}: " - f"{stderr.decode('utf-8')}" + f"Process exited with {self._process.returncode}: {stderr_text}" ) await self._emit_status( "error", reason="process_exit", - detail=stderr.decode("utf-8"), + detail=stderr_text, ) break else: continue - await self._broadcast(line.decode("utf-8").rstrip("\n")) + await self._broadcast(_decode_worker_output(line).rstrip("\n")) # Handle out message try: diff --git a/tests/web/test_session_error_recovery.py b/tests/web/test_session_error_recovery.py index 191a100b7..f9c6aafb4 100644 --- a/tests/web/test_session_error_recovery.py +++ b/tests/web/test_session_error_recovery.py @@ -129,6 +129,39 @@ async def tracking_broadcast(msg: str) -> None: assert sp.status.state == "error" +@pytest.mark.asyncio +async def test_read_loop_eof_handles_non_utf8_stderr() -> None: + sp = SessionProcess(uuid4()) + messages: list[str] = [] + + async def capture_broadcast(msg: str) -> None: + messages.append(msg) + + sp._broadcast = capture_broadcast # type: ignore[assignment] + + mock_stdout = asyncio.StreamReader() + mock_stdout.feed_eof() + + mock_stderr = asyncio.StreamReader() + mock_stderr.feed_data(b"windows dash: \x97") + mock_stderr.feed_eof() + + mock_process = MagicMock() + mock_process.stdout = mock_stdout + mock_process.stderr = mock_stderr + mock_process.returncode = 1 + + sp._process = mock_process + sp._expecting_exit = False + + await sp._read_loop() + + assert messages + assert "windows dash: �" in messages[0] + assert sp.status.reason == "process_exit" + assert sp.status.detail == "windows dash: �" + + # --------------------------------------------------------------------------- # Tests: error state allows recovery with new prompt # ---------------------------------------------------------------------------