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>
This commit is contained in:
jim800121chen 2026-07-23 02:00:16 +08:00
parent 4f316bb3b8
commit f62141a925
3 changed files with 339 additions and 1 deletions

View File

@ -123,8 +123,26 @@ func (d *KneronDriver) startPython() error {
// On macOS with Apple Silicon, Kneron SDK requires x86_64 (Rosetta 2).
// The venv should already contain the correct architecture Python.
// Set DYLD_LIBRARY_PATH so libkplus.dylib can be found.
// PYTHONUTF8=1 啟用 Python UTF-8 ModePEP 540讓子行程的 stdio 與
// locale.getencoding() 一律視為 UTF-8不理會系統 ANSI code page。
//
// 為什麼要在 Go 這端也設一次bridge 內已有 _force_utf8_stdio()
// 兩者涵蓋範圍不同,是互補而非重複 ——
//
// 1. bridge 的 reconfigure() 只能在「直譯器已經啟動、開始執行程式碼」
// 之後才生效。若 import 期間(例如 numpy / kp 載入失敗)就拋例外,
// traceback 仍會用舊編碼寫到 stderr中文路徑會變亂碼而難以診斷。
// 2. UTF-8 Mode 一併改變 locale.getencoding(),因此連 open() 這類
// 未顯式指定 encoding 的呼叫也會是 UTF-8 —— reconfigure() 對
// sys.std* 以外的檔案物件無能為力。
//
// 背景Windows 的 Python 預設把 stdio 綁到系統 ANSI code page繁中
// Windows 為 cp950。而 Go 的 encoding/json 不 escape 非 ASCII
// `{"labels":["布"]}` 在 wire 上是 raw UTF-8以 cp950 解碼會變成
// `撣<>`U+FFFD或在嚴格模式下直接讓 bridge 崩潰。
cmd.Env = append(os.Environ(),
"PYTHONUNBUFFERED=1",
"PYTHONUTF8=1",
)
// Add library path for native kp module if lib directory exists.

View File

@ -19,6 +19,58 @@ import io
import numpy as np
def _force_utf8_stdio():
"""把 stdin / stdout / stderr 一律綁成 UTF-8不理會系統預設編碼。
為什麼需要這支Windows 中文亂碼根因
Windows Python stdio 綁到系統 ANSI code page而非 UTF-8繁體
中文 Windows ANSI code page cp950而本 bridge JSON-RPC 兩個
方向並不對稱
Go Python (stdin)Go `encoding/json` **** escape ASCII
`{"labels":[""]}` wire 上就是 raw UTF-8 位元組 e5 b8 83
cp950 解碼會得到 `` + U+FFFD使用者截圖的亂碼
嚴格模式下則直接 UnicodeDecodeError 讓整個 bridge 掛掉
Python Go (stdout)`json.dumps` 預設 ensure_ascii=True中文被
escape \\uXXXX ASCII所以這條路目前剛好沒壞但那是
隱性依賴 任何人加上 ensure_ascii=False 就會壞這裡一併綁定
stdout UTF-8變成顯式契約而非巧合
stderr`_log()` 的中文訊息與 em dashU+2014等字元現在就會壞
使用者先前看到的 `<EFBFBD>X SDK did not report` 即為此
為什麼用 errors="replace" 而非預設的 "strict"
這是診斷用的 log 通道與協定通道遇到極端的無效位元組時我們寧可看到
一個 U+FFFD 也不要讓整個 bridge 因為一行 log 而崩潰 bridge 掛掉
會讓裝置直接失聯比一個壞字元嚴重得多stdin 側同理Go 端送出的一定
是合法 UTF-8replace 只是最後一道防線
為什麼在 module import 時就呼叫而不是在 main()
必須早於任何 I/Omain() os.dup() stdoutimport 期間的例外也會經由
stderr 輸出兩者都得在編碼已經正確之後才發生
Python 3.7+ 才有 TextIOWrapper.reconfigure()專案用的是
python-build-standalone 3.12安全仍以 hasattr 做防禦
在極舊環境下退化成 no-op 而不是 crash
"""
for name in ("stdin", "stdout", "stderr"):
stream = getattr(sys, name, None)
if stream is None:
continue # pythonw / 被重導向到 None 的情境
try:
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, ValueError, OSError):
# 已被換成非 TextIOWrapper例如測試的 StringIO或已關閉。
# 這不是致命錯誤,維持原編碼繼續跑。
pass
_force_utf8_stdio()
def _preload_kneron_dylibs_macos():
"""macOS 專用:用絕對路徑預先 dlopen wheel 內的 libusb + libkplus。
@ -2935,7 +2987,14 @@ def main():
# writes go to stderr. Our JSON responses use the duped fd.
_real_stdout_fd = os.dup(1) # duplicate fd 1
os.dup2(2, 1) # fd 1 now points to stderr
_real_stdout = os.fdopen(_real_stdout_fd, "w")
# encoding 必須顯式指定 —— os.fdopen() 不帶 encoding 時會用
# locale.getpreferredencoding(),在繁中 Windows 上是 cp950。這個檔案
# 物件是 JSON-RPC 的**回應通道**,且它是全新建立的,不受
# _force_utf8_stdio() 對 sys.stdout 的 reconfigure 影響,所以必須在
# 這裡各自綁一次。errors 理由同 _force_utf8_stdio():寧可壞一個字元
# 也不要讓 bridge 崩潰而導致裝置失聯。
_real_stdout = os.fdopen(
_real_stdout_fd, "w", encoding="utf-8", errors="replace")
sys.stdout = sys.stderr # Python-level redirect too
def _respond(obj):

View File

@ -0,0 +1,261 @@
#!/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 cp950JSON-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()`
中文訊息則是現在就會壞使用者先前看到的 `<EFBFBD>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 靜默產生 `<EFBFBD>`
修正後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)