From 34bc3615277b21a865325aed870023f6f768046f Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 23 Jul 2026 00:18:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(app):=20Python=20=E7=9B=B8=E4=BE=9D?= =?UTF-8?q?=E6=AA=A2=E6=9F=A5=E4=B8=8D=E5=BE=97=E9=98=BB=E6=96=B7=E5=95=9F?= =?UTF-8?q?=E5=8B=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一個 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) --- local-tool/visiona-local/app.go | 151 +++++++++++++--- local-tool/visiona-local/venv_health_test.go | 170 +++++++++++++++++++ 2 files changed, 300 insertions(+), 21 deletions(-) diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 7990ff9..1f8ff52 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "io" "net" @@ -106,6 +107,13 @@ type App struct { // 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。 bootstrapStatus string + // pythonDepsWarning 記錄「Python 相依健康檢查沒過、自動修復也沒成功」。 + // + // 刻意做成 warning 而非 error:這個檢查會誤判(見 ensureBundledPython 註解), + // 擋下啟動的代價遠大於放行。所以照常啟動、把訊息留在這裡讓使用者在 app 內 + // 看得到,而不是把人擋在啟動畫面外。 + pythonDepsWarning string + // L-1:server 健康偵測 goroutine 控制 watchCancel context.CancelFunc @@ -514,6 +522,24 @@ func (a *App) setBootstrapStatus(msg string) { a.appLog("bootstrap: %s", msg) } +// GetPythonDepsWarning 回傳 Python 相依健康檢查的警告訊息(空字串 = 沒問題)。 +// +// 前端可在控制台顯示這則訊息。它**不代表啟動失敗** —— 有這則訊息時 app 仍 +// 正常啟動,只是提醒使用者若推論 / 裝置掃描異常時該怎麼自救。 +func (a *App) GetPythonDepsWarning() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.pythonDepsWarning +} + +// setPythonDepsWarning 記錄相依修復失敗的訊息(不阻斷啟動)。 +func (a *App) setPythonDepsWarning(msg string) { + a.mu.Lock() + a.pythonDepsWarning = msg + a.mu.Unlock() + a.appLog("WARN python-deps: %s", msg) +} + // OpenBrowser 用系統預設瀏覽器開啟 URL。 func (a *App) OpenBrowser(url string) error { return openBrowser(url) @@ -948,24 +974,26 @@ func (a *App) ensureBundledPython() (string, error) { // 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。 // - // ⚠️ 「python 執行檔存在」不等於「相依裝好了」。實際踩過的情境:pip - // install 中途失敗(斷網 / 磁碟滿 / 防毒攔截),venv 與 python.exe 都在, - // 但 `import kp` 失敗。舊版只 Stat python.exe 就 return,於是每次啟動都 - // 跳過安裝、永遠卡在同一個錯誤且沒有任何提示 —— 使用者必須手動刪掉整個 - // runtime 目錄才能復原。所以這裡要連相依一起驗。 + // ⚠️ 「python 執行檔存在」不等於「相依裝好了」:pip install 中途失敗 + // (斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp` + // 失敗的半套環境。所以這裡會順手做一次健康檢查並嘗試修復。 + // + // ⚠️⚠️ 但健康檢查**絕不可以成為啟動的阻斷點**。理由: + // + // 1. 這個檢查會誤判。probe 跑的是裸的 `python -c "import kp"`,而 kp 在 + // Windows 要先把 site-packages\kp\lib 加進 PATH / add_dll_directory + // 才載得到 native DLL(見 platform_windows.go 的 driver 安裝腳本,以及 + // kl720_driver.go startPython() 為子行程注入的 PATH)。真正跑推論的 + // bridge 有這些設定、我們的 probe 沒有 → 環境健康也會被判成壞掉。 + // + // 2. 誤判的代價不對稱。放行的最壞情況是使用者進到 app 後看到「detector + // 掃不到裝置」這類具體錯誤(kneron_bridge.py 本來就把 import kp 當 + // optional:失敗只設 HAS_KP=False);擋下的最壞情況是 app 完全打不開、 + // 使用者連錯誤細節都看不到,比原本的 bug 更糟。 + // + // 所以:盡力修,修不好也放行,讓後續流程去報真正的錯。 if _, err := os.Stat(pythonBin); err == nil { - if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) { - return pythonBin, nil - } - // venv 在但相依壞了 → 只補裝 wheels,不整個重建 venv。 - // 重建 venv 要重新解壓 ~100MB tarball,而失敗幾乎都出在 pip 階段; - // 先試便宜的修法,真的不行再讓錯誤浮上來讓使用者看見。 - fmt.Fprintln(os.Stderr, "[visiona-local] venv 存在但 Python 相依不完整,重新安裝 wheels") - if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { - return "", fmt.Errorf("venv 已存在但 Python 相依不完整,自動修復失敗:%w\n"+ - "請手動刪除 %s 後重新啟動應用程式", err, runtimeDir) - } - return pythonBin, nil + return a.reuseExistingVenv(runtimeDir, pythonBin, wheelsDir), nil } // 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel @@ -1010,7 +1038,18 @@ func (a *App) ensureBundledPython() (string, error) { return "", fmt.Errorf("create venv: %w (%s)", err, string(out)) } + // 首次安裝:pip 真的失敗才算致命(沒有 venv 可用、後面一定跑不動)。 + // 但「pip 成功、只是 import 驗證沒過」不擋啟動 —— 那個 probe 會誤判。 if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { + if errors.Is(err, errPythonDepsProbeFailed) { + a.setPythonDepsWarning(fmt.Sprintf( + "Python 套件已安裝,但載入驗證未通過:%v\n"+ + "應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。", + err, runtimeDir)) + fmt.Fprintf(os.Stderr, + "[visiona-local] WARN: wheels 已裝但 import 驗證未過,仍繼續啟動:%v\n", err) + return pythonBin, nil + } return "", err } @@ -1018,6 +1057,33 @@ func (a *App) ensureBundledPython() (string, error) { return pythonBin, nil } +// reuseExistingVenv 處理「venv 已存在」的日常啟動路徑:驗健康、必要時嘗試修復, +// 然後**無論如何都回傳 pythonBin**。 +// +// 這個函式沒有 error 回傳值,是刻意的設計 —— 型別本身就保證了「健康檢查不會 +// 變成啟動的阻斷點」。要退化成阻斷版本必須改簽章,不會不小心發生。 +// +// 修不好時把原因寫進 pythonDepsWarning,讓使用者在 app 內看得到自救步驟, +// 而不是被擋在啟動畫面外面(那比原本的 bug 更糟)。 +func (a *App) reuseExistingVenv(runtimeDir, pythonBin, wheelsDir string) string { + if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) { + return pythonBin + } + + // venv 在但相依看起來壞了 → 只補裝 wheels,不整個重建 venv。 + // 重建 venv 要重新解壓 ~100MB tarball,而失敗幾乎都出在 pip 階段。 + fmt.Fprintln(os.Stderr, "[visiona-local] venv 存在但 Python 相依驗證未通過,嘗試重新安裝 wheels") + if err := a.installBundledWheels(runtimeDir, pythonBin, wheelsDir); err != nil { + a.setPythonDepsWarning(fmt.Sprintf( + "Python 相依自動修復未成功:%v\n"+ + "應用程式仍會啟動。若推論或裝置掃描異常,請關閉應用程式後刪除 %s 再重新啟動。", + err, runtimeDir)) + fmt.Fprintf(os.Stderr, + "[visiona-local] WARN: wheels 重裝失敗,改以現有 venv 繼續啟動:%v\n", err) + } + return pythonBin +} + // venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。 // // 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時 @@ -1068,6 +1134,13 @@ func (a *App) bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir string) } // probePythonModules 跑一次 `python -c "import "`,確認相依真的可用。 +// +// ⚠️ 這個 probe 的環境必須盡量貼近「真正跑 bridge 的環境」,否則會誤判。 +// kp 在 import 時就會 ctypes.CDLL 載入 kp/lib 下的 native DLL(libkplus / +// libusb-1.0 / libwdi + MinGW runtime)。Windows 上這些 DLL 不在預設搜尋路徑, +// 必須先 add_dll_directory —— kl720_driver.go 的 startPython() 與 +// platform_windows.go 的 driver 安裝腳本都有做這件事。probe 若不做, +// 健康的環境也會 import 失敗。所以這裡用一小段 preamble 補上。 func probePythonModules(pythonBin string, modules []string) error { if len(modules) == 0 { return nil @@ -1075,7 +1148,7 @@ func probePythonModules(pythonBin string, modules []string) error { ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) defer cancel() - cmd := exec.CommandContext(ctx, pythonBin, "-c", "import "+strings.Join(modules, ", ")) + cmd := exec.CommandContext(ctx, pythonBin, "-c", pythonProbeScript(modules)) configureSysProcAttr(cmd) out, err := cmd.CombinedOutput() if err != nil { @@ -1088,6 +1161,31 @@ func probePythonModules(pythonBin string, modules []string) error { return nil } +// pythonProbeScript 產生 probe 用的 python 程式碼。 +// +// 先把 site-packages/*/lib(kp 的 native DLL 目錄)掛進 DLL 搜尋路徑再 import, +// 對齊 bridge 實際執行時的環境。找不到那些目錄就單純 import,行為與舊版一致。 +func pythonProbeScript(modules []string) string { + return ` +import os, sys, sysconfig +try: + sp = sysconfig.get_paths().get('purelib') or '' + if sp and os.path.isdir(sp): + for pkg in os.listdir(sp): + libdir = os.path.join(sp, pkg, 'lib') + if not os.path.isdir(libdir): + continue + os.environ['PATH'] = libdir + os.pathsep + os.environ.get('PATH', '') + if hasattr(os, 'add_dll_directory'): + try: + os.add_dll_directory(libdir) + except OSError: + pass +except Exception: + pass +import ` + strings.Join(modules, ", ") + "\n" +} + // bundledWheelsFingerprint 產生 wheels 目錄的指紋(排序後的檔名清單)。 // // 用檔名而非內容雜湊:wheel 檔名本來就帶版本號(KneronPLUS-3.1.2-...whl), @@ -1113,10 +1211,18 @@ func listBundledWheelNames(wheelsDir string) []string { return names } +// errPythonDepsProbeFailed 標示「pip 安裝成功、但事後 import 驗證沒過」。 +// +// 與「pip 本身失敗」分開的原因:pip 失敗代表相依真的沒裝上(致命);probe 失敗 +// 則可能只是 probe 環境不足(Windows 缺 kp/lib 的 PATH / add_dll_directory), +// 呼叫端要能分辨並選擇放行。用 errors.Is 判斷。 +var errPythonDepsProbeFailed = errors.New("python deps probe failed") + // installBundledWheels 離線安裝 wheelsDir 下的所有 wheel,成功後寫入就緒標記。 // -// 標記只在 pip 回 0 之後才寫 —— 半套安裝不可留下「已就緒」的假象,那正是 -// 舊版讓使用者永久卡住的成因。 +// 標記只在 pip 回 0 **且** import 驗證通過後才寫 —— 半套安裝不可留下「已就緒」 +// 的假象,那正是舊版讓使用者永久卡住的成因。驗證沒過時回 errPythonDepsProbeFailed, +// 由呼叫端決定要不要放行(目前一律放行,只留 warning)。 func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error { names := listBundledWheelNames(wheelsDir) if len(names) == 0 { @@ -1143,8 +1249,11 @@ func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) erro // 安裝完立刻驗一次:pip 回 0 不保證 import 得起來(架構不符的 wheel、 // 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。 + // + // 包成 errPythonDepsProbeFailed,讓呼叫端能分辨「pip 失敗」(致命)與 + // 「只是 probe 沒過」(可能誤判,不該擋啟動)。 if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil { - return fmt.Errorf("wheels 安裝完成但相依無法載入:%w", err) + return fmt.Errorf("%w: wheels 安裝完成但相依無法載入:%w", errPythonDepsProbeFailed, err) } markerPath := filepath.Join(runtimeDir, venvReadyMarkerName) diff --git a/local-tool/visiona-local/venv_health_test.go b/local-tool/visiona-local/venv_health_test.go index 397f1ce..477c5a9 100644 --- a/local-tool/visiona-local/venv_health_test.go +++ b/local-tool/visiona-local/venv_health_test.go @@ -12,6 +12,7 @@ package main // 3. 壞掉的 venv 不會被誤判成健康 import ( + "errors" "os" "path/filepath" "runtime" @@ -19,6 +20,175 @@ import ( "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 {