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:
jim800121chen 2026-07-23 00:18:13 +08:00
parent f9fbc778be
commit 34bc361527
2 changed files with 300 additions and 21 deletions

View File

@ -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-1server 健康偵測 goroutine 控制 // L-1server 健康偵測 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 DLLlibkplus /
// 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/*/libkp 的 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)

View File

@ -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 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) { 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 {