diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e500e09c2..50190ec285 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Only write entries that are worth mentioning to users. ## Unreleased - Kosong: Stop sending an empty `anthropic-beta` header when no beta features are declared — adaptive thinking removes the interleaved-thinking beta, which previously left an empty header value that some backends reject +- Web/Vis: Fix `kimi web` and `kimi vis` dying at startup on consoles whose codec cannot encode the banner arrow (GBK on Chinese Windows, for example). The banner is printed before the server binds its port, so the crash left nothing listening; unsupported characters are now replaced instead of raising ## 1.49.0 (2026-07-16) diff --git a/src/kimi_cli/utils/server.py b/src/kimi_cli/utils/server.py index a33186ace5..64ea6df689 100644 --- a/src/kimi_cli/utils/server.py +++ b/src/kimi_cli/utils/server.py @@ -4,6 +4,7 @@ import importlib import socket +import sys import textwrap @@ -86,8 +87,28 @@ def get_network_addresses() -> list[str]: return addresses +def _encodable(text: str) -> str: + """Drop characters stdout cannot encode, so printing never raises. + + The banner uses characters like U+279C that a legacy console codec (GBK on + Chinese Windows, for example) cannot represent. Printing those raises + UnicodeEncodeError, and since the banner is printed before the server binds + its port, an unhandled error there takes the whole process down. + """ + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + try: + text.encode(encoding) + except UnicodeEncodeError: + return text.encode(encoding, errors="replace").decode(encoding, errors="replace") + except LookupError: + return text + return text + + def print_banner(lines: list[str]) -> None: """Print a boxed banner with tag conventions (
, ,
).""" + # Sanitize before measuring so the box borders still line up. + lines = [_encodable(line) for line in lines] processed: list[str] = [] for line in lines: if line == "
": diff --git a/tests/core/test_print_banner_encoding.py b/tests/core/test_print_banner_encoding.py new file mode 100644 index 0000000000..a8cfca6fb6 --- /dev/null +++ b/tests/core/test_print_banner_encoding.py @@ -0,0 +1,39 @@ +"""print_banner must not raise on consoles whose codec lacks banner glyphs. + +The banner is printed before the web/vis server binds its port, so an +unhandled UnicodeEncodeError there kills the process and the server never +starts. +""" + +from __future__ import annotations + +import contextlib +import io + +from kimi_cli.utils.server import print_banner + +# U+279C is what web/app.py and vis/app.py put in front of each URL. +_BANNER_LINE = " ➜ Local http://127.0.0.1:8000" + + +def _render(encoding: str) -> str: + """Render the banner to a stream using the given console encoding.""" + stream = io.TextIOWrapper(io.BytesIO(), encoding=encoding, errors="strict", newline="") + with contextlib.redirect_stdout(stream): + print_banner([_BANNER_LINE]) + stream.flush() + return stream.buffer.getvalue().decode(encoding) # type: ignore[attr-defined] + + +def test_print_banner_survives_unencodable_glyph() -> None: + # gbk is the Chinese-locale Windows console codec and cannot encode U+279C. + assert "http://127.0.0.1:8000" in _render("gbk") + + +def test_print_banner_box_stays_aligned() -> None: + lines = [line for line in _render("gbk").splitlines() if line] + assert len({len(line) for line in lines}) == 1, lines + + +def test_print_banner_keeps_glyph_when_encoding_supports_it() -> None: + assert "➜" in _render("utf-8")