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>
This commit is contained in:
parent
f9fbc778be
commit
34bc361527
@ -2,6 +2,7 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@ -106,6 +107,13 @@ type App struct {
|
|||||||
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
|
// 目前仍保留供開發 log 使用,最終在 M8-4b 整個流程改寫後會被拿掉。
|
||||||
bootstrapStatus string
|
bootstrapStatus string
|
||||||
|
|
||||||
|
// pythonDepsWarning 記錄「Python 相依健康檢查沒過、自動修復也沒成功」。
|
||||||
|
//
|
||||||
|
// 刻意做成 warning 而非 error:這個檢查會誤判(見 ensureBundledPython 註解),
|
||||||
|
// 擋下啟動的代價遠大於放行。所以照常啟動、把訊息留在這裡讓使用者在 app 內
|
||||||
|
// 看得到,而不是把人擋在啟動畫面外。
|
||||||
|
pythonDepsWarning string
|
||||||
|
|
||||||
// L-1:server 健康偵測 goroutine 控制
|
// L-1:server 健康偵測 goroutine 控制
|
||||||
watchCancel context.CancelFunc
|
watchCancel context.CancelFunc
|
||||||
|
|
||||||
@ -514,6 +522,24 @@ func (a *App) setBootstrapStatus(msg string) {
|
|||||||
a.appLog("bootstrap: %s", msg)
|
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。
|
// OpenBrowser 用系統預設瀏覽器開啟 URL。
|
||||||
func (a *App) OpenBrowser(url string) error {
|
func (a *App) OpenBrowser(url string) error {
|
||||||
return openBrowser(url)
|
return openBrowser(url)
|
||||||
@ -948,24 +974,26 @@ func (a *App) ensureBundledPython() (string, error) {
|
|||||||
|
|
||||||
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。
|
// 已建立好就直接回傳(幂等)— 日常啟動走這條,不會暫停 hard timeout。
|
||||||
//
|
//
|
||||||
// ⚠️ 「python 執行檔存在」不等於「相依裝好了」。實際踩過的情境:pip
|
// ⚠️ 「python 執行檔存在」不等於「相依裝好了」:pip install 中途失敗
|
||||||
// install 中途失敗(斷網 / 磁碟滿 / 防毒攔截),venv 與 python.exe 都在,
|
// (斷網 / 磁碟滿 / 防毒攔截)會留下 venv 與 python.exe 都在、但 `import kp`
|
||||||
// 但 `import kp` 失敗。舊版只 Stat python.exe 就 return,於是每次啟動都
|
// 失敗的半套環境。所以這裡會順手做一次健康檢查並嘗試修復。
|
||||||
// 跳過安裝、永遠卡在同一個錯誤且沒有任何提示 —— 使用者必須手動刪掉整個
|
//
|
||||||
// runtime 目錄才能復原。所以這裡要連相依一起驗。
|
// ⚠️⚠️ 但健康檢查**絕不可以成為啟動的阻斷點**。理由:
|
||||||
|
//
|
||||||
|
// 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 _, err := os.Stat(pythonBin); err == nil {
|
||||||
if a.bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir) {
|
return a.reuseExistingVenv(runtimeDir, pythonBin, wheelsDir), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 首次 bootstrap 路徑:解壓 tarball + 建 venv + pip install 9 個 wheel
|
// 首次 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))
|
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 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
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1018,6 +1057,33 @@ func (a *App) ensureBundledPython() (string, error) {
|
|||||||
return pythonBin, nil
|
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 下)。
|
// venvReadyMarkerName 是「wheels 已成功裝完」的標記檔名(放在 runtimeDir 下)。
|
||||||
//
|
//
|
||||||
// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時
|
// 存在此標記 = 上一次 installBundledWheels 完整跑完且 pip 回 0。內容是當時
|
||||||
@ -1068,6 +1134,13 @@ func (a *App) bundledPythonDepsHealthy(runtimeDir, pythonBin, wheelsDir string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// probePythonModules 跑一次 `python -c "import <mods>"`,確認相依真的可用。
|
// probePythonModules 跑一次 `python -c "import <mods>"`,確認相依真的可用。
|
||||||
|
//
|
||||||
|
// ⚠️ 這個 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 {
|
func probePythonModules(pythonBin string, modules []string) error {
|
||||||
if len(modules) == 0 {
|
if len(modules) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@ -1075,7 +1148,7 @@ func probePythonModules(pythonBin string, modules []string) error {
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
cmd := exec.CommandContext(ctx, pythonBin, "-c", "import "+strings.Join(modules, ", "))
|
cmd := exec.CommandContext(ctx, pythonBin, "-c", pythonProbeScript(modules))
|
||||||
configureSysProcAttr(cmd)
|
configureSysProcAttr(cmd)
|
||||||
out, err := cmd.CombinedOutput()
|
out, err := cmd.CombinedOutput()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -1088,6 +1161,31 @@ func probePythonModules(pythonBin string, modules []string) error {
|
|||||||
return nil
|
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 目錄的指紋(排序後的檔名清單)。
|
// bundledWheelsFingerprint 產生 wheels 目錄的指紋(排序後的檔名清單)。
|
||||||
//
|
//
|
||||||
// 用檔名而非內容雜湊:wheel 檔名本來就帶版本號(KneronPLUS-3.1.2-...whl),
|
// 用檔名而非內容雜湊:wheel 檔名本來就帶版本號(KneronPLUS-3.1.2-...whl),
|
||||||
@ -1113,10 +1211,18 @@ func listBundledWheelNames(wheelsDir string) []string {
|
|||||||
return names
|
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,成功後寫入就緒標記。
|
// installBundledWheels 離線安裝 wheelsDir 下的所有 wheel,成功後寫入就緒標記。
|
||||||
//
|
//
|
||||||
// 標記只在 pip 回 0 之後才寫 —— 半套安裝不可留下「已就緒」的假象,那正是
|
// 標記只在 pip 回 0 **且** import 驗證通過後才寫 —— 半套安裝不可留下「已就緒」
|
||||||
// 舊版讓使用者永久卡住的成因。
|
// 的假象,那正是舊版讓使用者永久卡住的成因。驗證沒過時回 errPythonDepsProbeFailed,
|
||||||
|
// 由呼叫端決定要不要放行(目前一律放行,只留 warning)。
|
||||||
func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error {
|
func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) error {
|
||||||
names := listBundledWheelNames(wheelsDir)
|
names := listBundledWheelNames(wheelsDir)
|
||||||
if len(names) == 0 {
|
if len(names) == 0 {
|
||||||
@ -1143,8 +1249,11 @@ func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) erro
|
|||||||
|
|
||||||
// 安裝完立刻驗一次:pip 回 0 不保證 import 得起來(架構不符的 wheel、
|
// 安裝完立刻驗一次:pip 回 0 不保證 import 得起來(架構不符的 wheel、
|
||||||
// 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。
|
// 缺系統層 DLL 等)。驗過才寫標記,否則下次啟動又會被快路徑放行。
|
||||||
|
//
|
||||||
|
// 包成 errPythonDepsProbeFailed,讓呼叫端能分辨「pip 失敗」(致命)與
|
||||||
|
// 「只是 probe 沒過」(可能誤判,不該擋啟動)。
|
||||||
if err := probePythonModules(pythonBin, venvHealthProbeModules); err != nil {
|
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)
|
markerPath := filepath.Join(runtimeDir, venvReadyMarkerName)
|
||||||
|
|||||||
@ -12,6 +12,7 @@ package main
|
|||||||
// 3. 壞掉的 venv 不會被誤判成健康
|
// 3. 壞掉的 venv 不會被誤判成健康
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@ -19,6 +20,175 @@ import (
|
|||||||
"testing"
|
"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) {
|
func writeFile(t *testing.T, path, content string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user