使用者截圖標籤顯示「撣�」而非「布」。實證:布 的 UTF-8 位元組 e5 b8 83 用 cp950 解碼正好得到「撣�」。 根因是 bridge 的 stdio 綁在系統 ANSI code page(繁中 Windows = cp950), 而非 UTF-8。兩個方向的表現不同: - Go → Python(stdin):Go 的 json.Marshal 不 escape 非 ASCII,送出的是 原始 UTF-8,Python 卻用 cp950 解碼 → 標籤壞掉。這是實際的損壞路徑。 - Python → Go(stdout):json.dumps 預設 ensure_ascii=True 會轉成 \uXXXX, 所以碰巧沒事 —— 但那是巧合不是設計。 同一根因也造成 log 的「(source: declared) �X SDK did not report」,那個 �X 是程式碼裡的 em dash 編碼失敗。 修正兩層,各自不可省: 1. kneron_bridge.py 在 module import 時強制 stdio 為 UTF-8(早於任何 I/O, import 期間的 traceback 也涵蓋),並在 os.fdopen 明確指定 encoding —— 那是 JSON-RPC 回應通道,reconfigure() 碰不到它,目前只靠 ensure_ascii 巧合存活。errors="replace" 是刻意的:bridge 崩潰會讓裝置 離線,比一個壞字元嚴重得多。 2. kl720_driver.go 加 PYTHONUTF8=1。實測 PYTHONIOENCODING 只影響 sys.stdin、 PYTHONUTF8 才管得到 os.fdopen 與 locale.getencoding(),單一層都不夠。 Go 端 stdout scanner 不需改(bufio.Scanner 是 byte-oriented,不做轉碼)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
262 lines
11 KiB
Python
262 lines
11 KiB
Python
#!/usr/bin/env python3
|
||
"""Unit tests for kneron_bridge stdio encoding (Windows 中文亂碼修正).
|
||
|
||
背景 —— 為什麼需要這組測試:
|
||
|
||
Windows 的 Python 預設把 stdin / stdout / stderr 綁到「系統 ANSI code page」
|
||
而非 UTF-8。繁體中文 Windows 的 ANSI code page 是 cp950。JSON-RPC 兩個方向
|
||
的行為並不對稱:
|
||
|
||
Go → Python (stdin):Go 的 `encoding/json` **不** escape 非 ASCII,
|
||
`{"labels":["布"]}` 在 wire 上是 raw UTF-8 位元組 e5 b8 83。
|
||
Python 端若以 cp950 解碼會得到 `撣` + U+FFFD —— 這正是使用者截圖
|
||
看到的亂碼。嚴格模式下則直接 UnicodeDecodeError 讓 bridge 整個掛掉。
|
||
|
||
Python → Go (stdout):`json.dumps` 預設 ensure_ascii=True,中文被 escape
|
||
成 \\uXXXX 純 ASCII,所以 JSON 回應這條路「目前」不會壞。但這是脆弱的
|
||
隱性依賴:任何人加上 ensure_ascii=False 就會壞。stderr 上的 `_log()`
|
||
中文訊息則是現在就會壞(使用者先前看到的 `<60>X SDK did not report`
|
||
就是 em dash U+2014 被非 UTF-8 編碼破壞的結果)。
|
||
|
||
因此修正必須同時涵蓋 stdin(decode)與 stdout / stderr(encode)。
|
||
|
||
測試策略 —— 如何在 macOS 上測 Windows-only 的 bug:
|
||
本機 locale 是 UTF-8,直接跑不會重現。改用 `PYTHONIOENCODING` 環境變數強制
|
||
子行程的 stdio 編碼,模擬繁中 Windows 的預設行為。修正正確時,bridge 必須
|
||
忽略這個「錯誤的」預設值、一律以 UTF-8 處理 stdio。
|
||
|
||
為什麼用 `unknown command` 這條路徑做斷言:
|
||
它是唯一「不需要實體 Kneron dongle、又會把輸入字串原樣 echo 回 stdout」的
|
||
handler,因此能驗證完整的 stdin → 解析 → stdout 往返。真正的 labels 路徑
|
||
(set_inference_options)需要已連線的裝置,在 CI / 本機無法觸及;但兩者共用
|
||
同一個 stdin 解碼器,所以這裡測到的就是同一個根因。
|
||
|
||
執行方式:
|
||
cd server/scripts && python3 -m unittest test_kneron_bridge_encoding
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import unittest
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
BRIDGE = os.path.join(HERE, "kneron_bridge.py")
|
||
|
||
# 取自使用者實際的 labels.txt。`布` 特別重要:UTF-8 位元組 (e5 b8 83) 以
|
||
# cp950 解碼會得到 `撣` + U+FFFD,正是截圖中的症狀。
|
||
CJK_LABELS = ["剪刀", "石頭", "布"]
|
||
|
||
# U+FFFD REPLACEMENT CHARACTER —— 解碼失敗的明確指紋。
|
||
REPLACEMENT_CHAR = "<EFBFBD>"
|
||
|
||
# 模擬非 UTF-8 系統預設編碼的情境。
|
||
# cp950 —— 繁中 Windows 的 ANSI code page(嚴格模式會拋例外)
|
||
# cp950:replace —— 靜默產生 U+FFFD 的最陰險情境(使用者實際看到的)
|
||
# latin-1 —— 確認修正不是只針對 cp950 硬編
|
||
NON_UTF8_STDIO_ENVS = ["cp950", "cp950:replace", "latin-1"]
|
||
|
||
|
||
def _run_bridge(commands, io_encoding=None, timeout=60):
|
||
"""把 commands 逐行餵給 bridge 子行程,回傳 (responses, proc)。
|
||
|
||
刻意走真實 subprocess 而非 import:這個 bug 的本質就是「行程啟動時
|
||
stdio 綁定了什麼編碼」,in-process 測試無法重現。
|
||
|
||
以 bytes 模式通訊:測試自己控制編解碼,才能精準模擬 Go 端 json.Marshal
|
||
送出 raw UTF-8 的行為,不受測試進程自身 locale 影響。
|
||
"""
|
||
env = os.environ.copy()
|
||
# 避免使用者環境既有的設定干擾測試前提。
|
||
env.pop("PYTHONIOENCODING", None)
|
||
if io_encoding is not None:
|
||
env["PYTHONIOENCODING"] = io_encoding
|
||
|
||
payload = b"".join(
|
||
json.dumps(c, ensure_ascii=False).encode("utf-8") + b"\n" for c in commands
|
||
)
|
||
|
||
proc = subprocess.run(
|
||
[sys.executable, BRIDGE],
|
||
input=payload,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
env=env,
|
||
timeout=timeout,
|
||
)
|
||
|
||
responses = []
|
||
for raw in proc.stdout.splitlines():
|
||
raw = raw.strip()
|
||
if not raw:
|
||
continue
|
||
try:
|
||
responses.append(json.loads(raw.decode("utf-8")))
|
||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||
# 保留原始 bytes,讓失敗訊息看得出是編碼問題而非邏輯問題。
|
||
responses.append({"_undecodable": raw})
|
||
return responses, proc
|
||
|
||
|
||
class StdinDecodingTest(unittest.TestCase):
|
||
"""stdin 方向:Go 送 raw UTF-8,bridge 必須正確解碼。"""
|
||
|
||
def _assert_cjk_roundtrip(self, io_encoding, scenario):
|
||
# 每個標籤各送一次,確保是逐字驗證而非整包碰運氣。
|
||
commands = [{"cmd": f"probe_{label}"} for label in CJK_LABELS]
|
||
responses, proc = _run_bridge(commands, io_encoding)
|
||
|
||
stderr_text = proc.stderr.decode("utf-8", "replace")
|
||
|
||
self.assertEqual(
|
||
proc.returncode, 0,
|
||
msg=(f"[{scenario}] bridge 非正常結束(rc={proc.returncode})。"
|
||
f"多半是 stdin 以非 UTF-8 解碼導致 UnicodeDecodeError。\n"
|
||
f"stderr=\n{stderr_text}"),
|
||
)
|
||
|
||
for r in responses:
|
||
self.assertNotIn(
|
||
"_undecodable", r,
|
||
msg=f"[{scenario}] stdout 不是合法 UTF-8 JSON:{r}",
|
||
)
|
||
|
||
errors = [r["error"] for r in responses if isinstance(r.get("error"), str)]
|
||
self.assertEqual(
|
||
len(errors), len(CJK_LABELS),
|
||
msg=(f"[{scenario}] 預期 {len(CJK_LABELS)} 筆 unknown-command 回應,"
|
||
f"實得 {len(errors)}。responses={responses}\n"
|
||
f"stderr=\n{stderr_text}"),
|
||
)
|
||
|
||
for label, err in zip(CJK_LABELS, errors):
|
||
self.assertNotIn(
|
||
REPLACEMENT_CHAR, err,
|
||
msg=(f"[{scenario}] 回應含 U+FFFD replacement character,"
|
||
f"代表 stdin 被以非 UTF-8 解碼:{err!r}"),
|
||
)
|
||
self.assertIn(
|
||
label, err,
|
||
msg=(f"[{scenario}] 中文字串 round-trip 失敗。"
|
||
f"預期回應含 {label!r},實得 {err!r}"),
|
||
)
|
||
|
||
def test_roundtrip_under_default_locale(self):
|
||
"""基準線:本機預設 locale(macOS / Linux 為 UTF-8)必須正常。
|
||
|
||
這條在修正前後都會過 —— 它的作用是證明測試本身沒問題,
|
||
失敗的是編碼而不是斷言寫錯。
|
||
"""
|
||
self._assert_cjk_roundtrip(None, "default-locale")
|
||
|
||
def test_roundtrip_under_non_utf8_stdio(self):
|
||
"""核心回歸測試:模擬繁中 Windows 的非 UTF-8 預設 stdio。
|
||
|
||
修正前:cp950 → UnicodeDecodeError 直接 crash;
|
||
cp950:replace → 靜默產生 `撣<>`。
|
||
修正後:bridge 強制 UTF-8,忽略 PYTHONIOENCODING 的錯誤指示。
|
||
"""
|
||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||
with self.subTest(encoding=io_encoding):
|
||
self._assert_cjk_roundtrip(io_encoding, io_encoding)
|
||
|
||
|
||
class StderrEncodingTest(unittest.TestCase):
|
||
"""stderr 方向:_log() 的中文 / 非 ASCII 診斷訊息必須可讀。"""
|
||
|
||
def test_stderr_is_valid_utf8_under_non_utf8_stdio(self):
|
||
"""使用者先前看到的 `<60>X SDK did not report` 與 label 亂碼同源。
|
||
|
||
stderr 若被綁到 cp950,em dash(U+2014)等非 ASCII 字元會被破壞,
|
||
讓診斷訊息失去價值。
|
||
"""
|
||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||
with self.subTest(encoding=io_encoding):
|
||
_, proc = _run_bridge([{"cmd": "probe_布"}], io_encoding)
|
||
try:
|
||
proc.stderr.decode("utf-8")
|
||
except UnicodeDecodeError as e:
|
||
self.fail(
|
||
f"[{io_encoding}] stderr 不是合法 UTF-8,"
|
||
f"編碼修正未涵蓋 stderr:{e}")
|
||
|
||
def test_startup_banner_present(self):
|
||
"""確認 bridge 真的有跑起來(避免上面的測試因空輸出而假綠)。"""
|
||
_, proc = _run_bridge([{"cmd": "probe_布"}], "cp950:replace")
|
||
self.assertIn(
|
||
"[kneron_bridge]", proc.stderr.decode("utf-8", "replace"),
|
||
msg="bridge 啟動 log 不存在,測試前提不成立")
|
||
|
||
|
||
class StdoutEncodingTest(unittest.TestCase):
|
||
"""stdout 方向:JSON 回應必須能以 UTF-8 解碼。"""
|
||
|
||
def test_stdout_is_valid_utf8_with_non_ascii_payload(self):
|
||
"""即使未來有人把 json.dumps 改成 ensure_ascii=False 也不能壞。
|
||
|
||
目前 ensure_ascii=True 讓中文變成 \\uXXXX 純 ASCII,掩蓋了 stdout
|
||
的編碼問題。這條測試把「stdout 必須是 UTF-8」釘成顯式契約,
|
||
而不是依賴 json.dumps 的預設值。
|
||
"""
|
||
for io_encoding in NON_UTF8_STDIO_ENVS:
|
||
with self.subTest(encoding=io_encoding):
|
||
responses, proc = _run_bridge(
|
||
[{"cmd": "probe_布"}], io_encoding)
|
||
for r in responses:
|
||
self.assertNotIn(
|
||
"_undecodable", r,
|
||
msg=(f"[{io_encoding}] stdout 無法以 UTF-8 解碼:{r}"))
|
||
|
||
|
||
class RealStdoutFdEncodingTest(unittest.TestCase):
|
||
"""main() 裡 os.fdopen() 建立的 JSON-RPC 回應通道必須顯式綁 UTF-8。
|
||
|
||
⚠️ 這條為什麼是原始碼層級的斷言,而不是行為測試:
|
||
|
||
`os.fdopen(fd, "w")` 不帶 encoding 時,編碼取自
|
||
`locale.getencoding()`(PEP 686 之後 io.text_encoding(None) → "locale"),
|
||
**不是** `PYTHONIOENCODING`。PYTHONIOENCODING 只影響 sys.std*。
|
||
|
||
後果:在 macOS / Linux(locale 為 UTF-8)上,無論怎麼設
|
||
PYTHONIOENCODING 都無法讓這個檔案物件變成 cp950,因此
|
||
上面的 subprocess 測試**結構上碰不到這條路徑**(已用 mutation test
|
||
證實:拿掉 encoding= 參數後那些測試仍然全綠)。
|
||
|
||
但在繁中 Windows 上 locale.getencoding() 就是 cp950,這個 fd
|
||
會真的以 cp950 編碼寫出 JSON 回應。目前 json.dumps 的
|
||
ensure_ascii=True 讓中文變成純 ASCII 而僥倖沒出事 —— 這是巧合,
|
||
不是設計。
|
||
|
||
唯一能在 macOS 上守住這條的方式,就是把「必須顯式指定 encoding」
|
||
當成不可回歸的原始碼契約來檢查。
|
||
"""
|
||
|
||
def test_fdopen_specifies_utf8_explicitly(self):
|
||
with open(BRIDGE, encoding="utf-8") as f:
|
||
source = f.read()
|
||
|
||
self.assertIn(
|
||
"_real_stdout_fd = os.dup(1)", source,
|
||
msg="main() 的 stdout dup 結構已改變,本測試需同步更新")
|
||
|
||
# 找出建立 _real_stdout 的那個 os.fdopen 呼叫(可能跨行)。
|
||
marker = "_real_stdout = os.fdopen("
|
||
idx = source.find(marker)
|
||
self.assertNotEqual(
|
||
idx, -1, msg="找不到 _real_stdout = os.fdopen(...) 呼叫")
|
||
|
||
# 取到該敘述結束(右括號)為止,避免誤抓到後面無關的程式碼。
|
||
call = source[idx:source.find(")", idx) + 1]
|
||
|
||
self.assertIn(
|
||
'encoding="utf-8"', call,
|
||
msg=(f"os.fdopen() 未顯式指定 encoding=\"utf-8\","
|
||
f"在繁中 Windows 上會退回 cp950 編碼 JSON-RPC 回應。"
|
||
f"實際呼叫:{call!r}"))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main(verbosity=2)
|