上一個 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>
372 lines
14 KiB
Go
372 lines
14 KiB
Go
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 DLL(libkplus / libusb-1.0 /
|
||
// libwdi)。Windows 上這些 DLL 不在預設搜尋路徑,要先 add_dll_directory —— 真正
|
||
// 跑 bridge 的 kl720_driver.go startPython() 有注入 PATH,probe 沒有。
|
||
//
|
||
// 代價不對稱:放行 → 使用者進 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")
|
||
|
||
// 假 python:import 失敗、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)
|
||
}
|
||
}
|