visionA/local-tool/visiona-local/venv_health_test.go
jim800121chen 34bc361527 fix(app): Python 相依檢查不得阻斷啟動
上一個 commit 讓 Windows 完全開不了:階段 2 直接失敗,
「python runtime unavailable: no python runtime available」。

根因:健康檢查的 probe 環境與實際跑推論的環境不一致。
`import kp` 在 import 當下就要載入 6 個 native DLL(libkplus / libusb /
libwdi 等),這些不在 Windows 預設搜尋路徑。專案裡每一處真正用 kp 的地方
都自己補了路徑(kl720_driver.go:142 注入 PATH、platform_windows.go:68
用 add_dll_directory),只有新加的 probe 是裸跑 python -c "import kp"。

於是健康的 venv 被判成壞掉 → 重裝 wheels → 用同一個壞 probe 再驗一次 →
仍失敗 → 回 error → 連 app 都進不去。使用者前一天才手動重建好、推論正常
的環境,裝上新版就完全無法啟動。

修正:

- reuseExistingVenv 不回傳 error。venv 存在時的回傳值恆等於舊版
  (永遠是 pythonBin, nil),要退化成阻斷版必須改簽章,不會不小心發生
- 首次安裝路徑區分致命與非致命:pip 真的失敗才算致命,probe 沒過就放行
  並記 warning
- probe 在 import 前補上 DLL 搜尋路徑,對齊 kl720_driver.go 的實際做法
- 新增 GetPythonDepsWarning binding,讓提示出現在 app 內而非啟動阻斷點

原本要解的「wheels 半套安裝永久卡住」仍然有解,但改為非阻斷:修不好也讓
使用者進到 app 看見具體原因,而不是擋在啟動畫面外。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:18:13 +08:00

372 lines
14 KiB
Go
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.

package main
// venv_health_test.go — bundled Python venv 健康檢查單元測試
//
// 背景:舊版 ensureBundledPython 只 os.Stat(python.exe) 就視為就緒。實機踩到的
// 情境是 python.exe 在、但 `import kp` 失敗pip 中途失敗留下半套 venv
// 於是每次啟動都跳過安裝、永遠卡住且無任何提示,使用者只能手動刪整個 runtime。
//
// 這裡驗證的三件事:
// 1. 指紋wheels 清單)能偵測到 wheels 換版
// 2. 標記檔的快路徑不會啟動任何 process成本
// 3. 壞掉的 venv 不會被誤判成健康
import (
"errors"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// 迴歸測試:健康檢查不得成為啟動的阻斷點
//
// 事故背景commit f9fbc77健康檢查失敗時 ensureBundledPython 直接回 error
// Windows 使用者的 app 在啟動階段 2 就掛掉、完全打不開。
//
// 而這個健康檢查**本來就會誤判**probe 跑裸的 `python -c "import kp"`,但 kp
// 在 import 時就 ctypes.CDLL 載入 kp/lib 下的 native DLLlibkplus / libusb-1.0 /
// libwdi。Windows 上這些 DLL 不在預設搜尋路徑,要先 add_dll_directory —— 真正
// 跑 bridge 的 kl720_driver.go startPython() 有注入 PATHprobe 沒有。
//
// 代價不對稱:放行 → 使用者進 app 後看到具體錯誤bridge 本來就把 import kp 當
// optional擋下 → app 完全開不了。所以下面這幾條測試釘住「修不好也要放行」。
// ---------------------------------------------------------------------------
// 使用者實機情境venv 在、python 可執行、但 import kp 失敗Windows 缺 DLL 路徑),
// 且 wheels 重裝也失敗。必須仍然回傳 pythonBin。
func TestReuseExistingVenv_ImportFailsAndRepairFails_StillReturnsPythonBin(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl")
// 假 pythonimport 失敗、pip 也失敗(模擬最壞情況)
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython,
"#!/bin/sh\necho \"ImportError: DLL load failed while importing kp\" >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
got := a.reuseExistingVenv(runtimeDir, fakePython, wheels)
if got != fakePython {
t.Fatalf("健康檢查失敗時仍必須回傳 pythonBin不可阻斷啟動\ngot=%q want=%q", got, fakePython)
}
// 而且要留下讓使用者看得到的警告,不能靜默
if w := a.GetPythonDepsWarning(); w == "" {
t.Fatal("修復失敗時必須留下 warning 讓使用者在 app 內看到自救步驟")
} else if !strings.Contains(w, runtimeDir) {
t.Fatalf("warning 應告知要刪除哪個目錄got: %q", w)
}
}
// 健康的 venv 不該被打擾:回傳 pythonBin 且不留 warning。
func TestReuseExistingVenv_HealthyVenvReturnsBinWithoutWarning(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
// 標記相符 → 快路徑,不執行 python
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), bundledWheelsFingerprint(wheels))
a := &App{}
pythonBin := filepath.Join(runtimeDir, "python3")
if got := a.reuseExistingVenv(runtimeDir, pythonBin, wheels); got != pythonBin {
t.Fatalf("健康的 venv 應直接回傳 pythonBin, got=%q", got)
}
if w := a.GetPythonDepsWarning(); w != "" {
t.Fatalf("健康的 venv 不該留 warning, got: %q", w)
}
}
// 舊版留下的 venv無標記+ import 驗證通過 → 放行且不留 warning。
func TestReuseExistingVenv_LegacyVenvThatPassesProbeIsSilent(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\nexit 0\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
if got := a.reuseExistingVenv(runtimeDir, fakePython, wheels); got != fakePython {
t.Fatalf("probe 通過的舊 venv 應放行, got=%q", got)
}
if w := a.GetPythonDepsWarning(); w != "" {
t.Fatalf("probe 通過時不該留 warning, got: %q", w)
}
}
// installBundledWheels 必須能區分「pip 失敗」與「只是 probe 沒過」,
// 否則首次安裝路徑無法決定該不該放行。
func TestInstallBundledWheels_ProbeFailureIsDistinguishableFromPipFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
// pip install 成功(有參數時 exit 0、但 -c 的 import probe 失敗
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, `#!/bin/sh
for arg in "$@"; do
if [ "$arg" = "-c" ]; then
echo "ImportError: DLL load failed while importing kp" >&2
exit 1
fi
done
exit 0
`)
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
err := a.installBundledWheels(runtimeDir, fakePython, wheels)
if err == nil {
t.Fatal("probe 失敗時應回報錯誤")
}
if !errors.Is(err, errPythonDepsProbeFailed) {
t.Fatalf("probe 失敗必須可用 errors.Is 辨識(呼叫端要據此放行), got: %v", err)
}
// probe 沒過就不可留下就緒標記,否則下次啟動被快路徑放行、真壞掉也發現不了
if _, statErr := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); statErr == nil {
t.Fatal("probe 未通過時不可寫入就緒標記")
}
}
// pip 真的失敗(相依根本沒裝上)不該被誤標成 probe 失敗 —— 那是致命錯誤。
func TestInstallBundledWheels_PipFailureIsNotProbeFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\necho 'pip: disk full' >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
err := a.installBundledWheels(runtimeDir, fakePython, wheels)
if err == nil {
t.Fatal("pip 失敗時應回報錯誤")
}
if errors.Is(err, errPythonDepsProbeFailed) {
t.Fatalf("pip 失敗不可被歸類為 probe 失敗(兩者處置不同), got: %v", err)
}
}
// probe script 必須在 import 前處理好 native DLL 搜尋路徑,
// 否則 Windows 上健康的環境也會被判成壞掉(本次事故的成因之一)。
func TestPythonProbeScript_SetsUpDLLSearchPathBeforeImport(t *testing.T) {
script := pythonProbeScript([]string{"kp", "numpy"})
if !strings.Contains(script, "add_dll_directory") {
t.Error("probe script 必須呼叫 add_dll_directory否則 Windows 載不到 kp 的 native DLL")
}
if !strings.Contains(script, "PATH") {
t.Error("probe script 必須把 lib 目錄加進 PATH")
}
if !strings.Contains(script, "import kp, numpy") {
t.Errorf("probe script 應 import 指定模組, got:\n%s", script)
}
// DLL 路徑設定必須在 import 之前,順序錯了等於沒做
if strings.Index(script, "add_dll_directory") > strings.Index(script, "import kp, numpy") {
t.Error("DLL 搜尋路徑設定必須在 import 目標模組之前")
}
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
func wheelsDirWith(t *testing.T, names ...string) string {
t.Helper()
dir := t.TempDir()
for _, n := range names {
writeFile(t, filepath.Join(dir, n), "x")
}
return dir
}
func TestBundledWheelsFingerprint_StableRegardlessOfReadOrder(t *testing.T) {
a := wheelsDirWith(t, "numpy-2.4.4.whl", "KneronPLUS-3.1.2.whl", "opencv.whl")
b := wheelsDirWith(t, "opencv.whl", "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
if bundledWheelsFingerprint(a) != bundledWheelsFingerprint(b) {
t.Fatalf("fingerprint 應與檔案列舉順序無關\na=%q\nb=%q",
bundledWheelsFingerprint(a), bundledWheelsFingerprint(b))
}
}
func TestBundledWheelsFingerprint_ChangesWhenWheelVersionChanges(t *testing.T) {
// 這是升級 KneronPLUS 時觸發重裝的機制wheel 檔名帶版本號。
old := wheelsDirWith(t, "KneronPLUS-2.0.0-py3-none-any.whl", "numpy-2.4.4.whl")
upgraded := wheelsDirWith(t, "KneronPLUS-3.1.2-py3-none-any.whl", "numpy-2.4.4.whl")
if bundledWheelsFingerprint(old) == bundledWheelsFingerprint(upgraded) {
t.Fatal("wheels 換版後 fingerprint 必須改變,否則升級不會觸發重裝")
}
}
func TestBundledWheelsFingerprint_IgnoresNonWheelFiles(t *testing.T) {
dir := wheelsDirWith(t, "numpy-2.4.4.whl")
writeFile(t, filepath.Join(dir, "README.txt"), "not a wheel")
writeFile(t, filepath.Join(dir, ".DS_Store"), "junk")
if got := bundledWheelsFingerprint(dir); got != "numpy-2.4.4.whl" {
t.Fatalf("fingerprint=%q, 只應包含 .whl", got)
}
}
func TestBundledWheelsFingerprint_MissingDirIsEmpty(t *testing.T) {
if got := bundledWheelsFingerprint(filepath.Join(t.TempDir(), "nope")); got != "" {
t.Fatalf("不存在的目錄 fingerprint=%q, want empty", got)
}
}
// 快路徑:標記檔內容與 wheels 相符 → 直接放行,**不執行 pythonBin**。
// 用一個不存在的 pythonBin 路徑證明它真的沒被執行(真跑會失敗)。
func TestBundledPythonDepsHealthy_MarkerFastPathSkipsProcess(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName),
bundledWheelsFingerprint(wheels))
a := &App{}
nonExistentPython := filepath.Join(runtimeDir, "definitely-not-a-python")
if !a.bundledPythonDepsHealthy(runtimeDir, nonExistentPython, wheels) {
t.Fatal("標記相符時應走快路徑回 true且不得執行 python")
}
}
// 標記內容與當前 wheels 不符(升級情境)→ 必須離開快路徑去實跑驗證。
// pythonBin 不存在 → 驗證失敗 → 回 false觸發重裝
func TestBundledPythonDepsHealthy_StaleMarkerTriggersRevalidation(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl")
writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName),
"KneronPLUS-2.0.0.whl") // 舊版留下的標記
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) {
t.Fatal("標記過期且無法實跑驗證時,不可回報健康")
}
}
// 沒有標記檔(舊版 venv / 首次升級到本版)→ 走慢路徑實跑驗證。
func TestBundledPythonDepsHealthy_NoMarkerAndBrokenPythonIsUnhealthy(t *testing.T) {
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "numpy-2.4.4.whl")
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) {
t.Fatal("無標記且 python 不可執行時,不可回報健康")
}
}
// 這是使用者實機踩到的核心情境python 執行檔在、但 import kp 失敗。
// 舊版只 Stat 檔案存在就放行;新版必須判定為不健康。
func TestBundledPythonDepsHealthy_PythonExistsButImportFails(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl")
// 假 python任何呼叫都以非 0 結束,模擬 import kp 失敗
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\necho \"ModuleNotFoundError: No module named 'kp'\" >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
if a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) {
t.Fatal("import 失敗的 venv 必須判定為不健康(這正是使用者卡住的情境)")
}
// 且不可留下就緒標記,否則下次啟動又被快路徑放行
if _, err := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); err == nil {
t.Fatal("驗證失敗時不可寫入就緒標記")
}
}
// import 成功 → 回 true 並補寫標記,讓下次啟動走快路徑(成本設計的關鍵)。
func TestBundledPythonDepsHealthy_HealthyPythonWritesMarker(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
runtimeDir := t.TempDir()
wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl")
fakePython := filepath.Join(runtimeDir, "python3")
writeFile(t, fakePython, "#!/bin/sh\nexit 0\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
a := &App{}
if !a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) {
t.Fatal("import 成功時應回報健康")
}
got, err := os.ReadFile(filepath.Join(runtimeDir, venvReadyMarkerName))
if err != nil {
t.Fatalf("應補寫就緒標記讓下次走快路徑: %v", err)
}
if strings.TrimSpace(string(got)) != bundledWheelsFingerprint(wheels) {
t.Fatalf("標記內容=%q, want=%q", got, bundledWheelsFingerprint(wheels))
}
}
func TestProbePythonModules_EmptyModuleListIsNoop(t *testing.T) {
// 空清單不該啟動 process傳不存在的路徑也不能失敗
if err := probePythonModules("/definitely/not/a/python", nil); err != nil {
t.Fatalf("空模組清單應為 no-op, got %v", err)
}
}
func TestProbePythonModules_ReportsStderrOnFailure(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell-script 假 python 在 Windows 上不適用")
}
dir := t.TempDir()
fakePython := filepath.Join(dir, "python3")
writeFile(t, fakePython, "#!/bin/sh\necho \"No module named 'kp'\" >&2\nexit 1\n")
if err := os.Chmod(fakePython, 0o755); err != nil {
t.Fatalf("chmod: %v", err)
}
err := probePythonModules(fakePython, []string{"kp"})
if err == nil {
t.Fatal("非 0 結束碼應回報錯誤")
}
// 錯誤訊息要帶上 python 的 stderr否則使用者看不到真正原因
if !strings.Contains(err.Error(), "No module named") {
t.Fatalf("錯誤訊息應包含 python stderr, got: %v", err)
}
}
func TestListBundledWheelNames_MissingDirReturnsNil(t *testing.T) {
if got := listBundledWheelNames(filepath.Join(t.TempDir(), "nope")); len(got) != 0 {
t.Fatalf("不存在的目錄應回空, got %v", got)
}
}