visionA/local-tool/server/scripts/test_kneron_bridge_encoding.py
jim800121chen f62141a925 fix(bridge): Windows 上中文標籤與 log 亂碼
使用者截圖標籤顯示「撣�」而非「布」。實證:布 的 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>
2026-07-23 02:00:16 +08:00

262 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 編碼破壞的結果)。
因此修正必須同時涵蓋 stdindecode與 stdout / stderrencode
測試策略 —— 如何在 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-8bridge 必須正確解碼。"""
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):
"""基準線:本機預設 localemacOS / 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 若被綁到 cp950em dashU+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 / Linuxlocale 為 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)