diff --git a/local-tool/server/internal/driver/kneron/kl720_driver.go b/local-tool/server/internal/driver/kneron/kl720_driver.go index b29212c..ada430f 100644 --- a/local-tool/server/internal/driver/kneron/kl720_driver.go +++ b/local-tool/server/internal/driver/kneron/kl720_driver.go @@ -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 Mode(PEP 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. diff --git a/local-tool/server/scripts/kneron_bridge.py b/local-tool/server/scripts/kneron_bridge.py index 0c32daa..352babf 100644 --- a/local-tool/server/scripts/kneron_bridge.py +++ b/local-tool/server/scripts/kneron_bridge.py @@ -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 dash(U+2014)等字元現在就會壞 + (使用者先前看到的 `�X SDK did not report` 即為此)。 + + 為什麼用 errors="replace" 而非預設的 "strict": + 這是診斷用的 log 通道與協定通道。遇到極端的無效位元組時,我們寧可看到 + 一個 U+FFFD 也不要讓整個 bridge 因為一行 log 而崩潰 —— bridge 掛掉 + 會讓裝置直接失聯,比一個壞字元嚴重得多。stdin 側同理:Go 端送出的一定 + 是合法 UTF-8,replace 只是最後一道防線。 + + 為什麼在 module import 時就呼叫(而不是在 main() 裡): + 必須早於任何 I/O。main() 會 os.dup() stdout、import 期間的例外也會經由 + 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): diff --git a/local-tool/server/scripts/test_kneron_bridge_encoding.py b/local-tool/server/scripts/test_kneron_bridge_encoding.py new file mode 100644 index 0000000..7be2f05 --- /dev/null +++ b/local-tool/server/scripts/test_kneron_bridge_encoding.py @@ -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 是 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()` + 中文訊息則是現在就會壞(使用者先前看到的 `�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 = "�" + +# 模擬非 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): + """使用者先前看到的 `�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)