Windows 啟動卡在階段 2,pip 在 2 秒內失敗:
ERROR: Cannot install certifi 2026.2.25 and certifi 2026.6.17
because these package versions have conflicting dependencies.
ERROR: ResolutionImpossible
根因:Makefile 的 vendor-wheels* 用 pip download --dest 但從不清空目錄,
payload-* 複製時也只疊上去不清。upstream 每發一次新版就多留一顆,於是
`pip install *.whl` 同時收到同一套件的多個版本,相依解析器立刻放棄。
vendor/wheels/darwin 目前 16 顆 = 9 個套件 + 7 顆重複舊版,與 log 的
「正在安裝 16 個 Python 套件」完全吻合。
三層修正:
1. ensurePythonRuntime 的 Auto 分支不再丟棄 bundled/system 的失敗原因。
這是使用者連續三輪拿不到線索的原因 —— 它不是根因,是放大器。
pip 的完整輸出本來就在 error 裡,只是被這裡擋掉。
2. selectLatestWheelPerPackage:送 pip 前每個套件只留最新版。這層才救得了
已經出貨的安裝包,只修 Makefile 對使用者手上那包無效。套件名依 PEP 503
正規化,版本用整數逐段比(避免 2.9 > 2.10 的字典序錯誤)。
3. Makefile 新增 clean_wheels_dir(沿用 copy_bundled_data 的路徑防護),
vendor-wheels* 先清再下載、payload-* 複製前先清,從源頭杜絕。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
366 lines
14 KiB
Go
366 lines
14 KiB
Go
package main
|
||
|
||
// wheel_dedup_test.go — 「同套件多版本 wheel 導致 pip 秒退」的迴歸測試
|
||
//
|
||
// 事故背景(使用者連續三輪 Windows 啟動失敗):
|
||
//
|
||
// [00:41:59] bootstrap: 正在安裝 16 個 Python 套件 ...
|
||
// [00:42:01] startup: ctrl.Start failed: python runtime unavailable:
|
||
// no python runtime available (tried bundled + system)
|
||
//
|
||
// 兩個獨立的 bug 疊在一起:
|
||
//
|
||
// 1. 根因:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/<os>`,
|
||
// 該目錄從不清空,upstream 每出新版就多留一顆 whl。實機 vendor/wheels/darwin
|
||
// 已累積成 certifi ×3、numpy ×2、idna ×2 … 共 16 顆(正常應為 9 顆,log 裡
|
||
// 的「16 個套件」就是這麼來的)。把重複版本一起丟給
|
||
// `pip install a.whl b.whl` 會 ResolutionImpossible,~0.5 秒退出 —— 正是
|
||
// 使用者看到的「2 秒失敗」。
|
||
//
|
||
// 2. 放大器:ensurePythonRuntime 的 Auto 分支把 bundled / system 的 err 直接
|
||
// 丟棄,只回一句通用的 "no python runtime available",使得上面那個明確的
|
||
// pip 錯誤永遠不會出現在使用者眼前,連續三輪無法定位。
|
||
//
|
||
// 下面的測試分別釘死這兩件事。
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1. 根因:同套件多版本必須只留最新一顆
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 使用者實機的 wheels 目錄內容(vendor/wheels/darwin,16 顆)。
|
||
// 這組資料就是會讓 pip ResolutionImpossible 的那組。
|
||
var realWorldDuplicatedWheels = []string{
|
||
"KneronPLUS-2.0.0-py3-none-any.whl",
|
||
"certifi-2026.2.25-py3-none-any.whl",
|
||
"certifi-2026.6.17-py3-none-any.whl",
|
||
"certifi-2026.7.22-py3-none-any.whl",
|
||
"charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl",
|
||
"charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl",
|
||
"idna-3.11-py3-none-any.whl",
|
||
"idna-3.18-py3-none-any.whl",
|
||
"numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl",
|
||
"numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl",
|
||
"opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl",
|
||
"pyusb-1.3.1-py3-none-any.whl",
|
||
"requests-2.33.1-py3-none-any.whl",
|
||
"requests-2.34.2-py3-none-any.whl",
|
||
"urllib3-2.6.3-py3-none-any.whl",
|
||
"urllib3-2.7.0-py3-none-any.whl",
|
||
}
|
||
|
||
// 核心迴歸:16 顆多版本 wheels → 每個套件只剩一顆,且都是最新版。
|
||
func TestSelectLatestWheelPerPackage_RealWorldDuplicates(t *testing.T) {
|
||
paths, dropped := selectLatestWheelPerPackage("/w", realWorldDuplicatedWheels)
|
||
|
||
// 9 個不同套件:KneronPLUS certifi charset_normalizer idna numpy
|
||
// opencv_python_headless pyusb requests urllib3
|
||
if len(paths) != 9 {
|
||
t.Fatalf("16 顆多版本 wheels 應收斂成 9 顆(每套件一顆)\ngot=%d\n%v", len(paths), paths)
|
||
}
|
||
if len(dropped) != 7 {
|
||
t.Fatalf("應丟棄 7 顆舊版\ngot=%d %v", len(dropped), dropped)
|
||
}
|
||
|
||
want := []string{
|
||
"KneronPLUS-2.0.0-py3-none-any.whl",
|
||
"certifi-2026.7.22-py3-none-any.whl",
|
||
"charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl",
|
||
"idna-3.18-py3-none-any.whl",
|
||
"numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl",
|
||
"opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl",
|
||
"pyusb-1.3.1-py3-none-any.whl",
|
||
"requests-2.34.2-py3-none-any.whl",
|
||
"urllib3-2.7.0-py3-none-any.whl",
|
||
}
|
||
for i, w := range want {
|
||
if got := filepath.Base(paths[i]); got != w {
|
||
t.Errorf("paths[%d] = %q, want %q", i, got, w)
|
||
}
|
||
}
|
||
|
||
// 最關鍵的性質:pip 參數中不得有任何套件出現兩次(有就會 ResolutionImpossible)
|
||
assertNoDuplicatePackages(t, paths)
|
||
}
|
||
|
||
// 性質測試:不論輸入多少重複版本,輸出永遠每套件至多一顆。
|
||
// 這是「pip 不會再 ResolutionImpossible」的充分條件。
|
||
func TestSelectLatestWheelPerPackage_NeverEmitsDuplicatePackage(t *testing.T) {
|
||
cases := [][]string{
|
||
realWorldDuplicatedWheels,
|
||
{"numpy-1.0-py3-none-any.whl", "numpy-2.0-py3-none-any.whl", "numpy-3.0-py3-none-any.whl"},
|
||
// 正規化:底線 vs 連字號應視為同一套件
|
||
{"opencv_python_headless-4.10.0-py3-none-any.whl", "opencv-python-headless-4.13.0-py3-none-any.whl"},
|
||
// 大小寫差異
|
||
{"Certifi-2026.1.1-py3-none-any.whl", "certifi-2026.2.2-py3-none-any.whl"},
|
||
}
|
||
for i, names := range cases {
|
||
paths, _ := selectLatestWheelPerPackage("/w", names)
|
||
t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) {
|
||
assertNoDuplicatePackages(t, paths)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 乾淨的 wheels 目錄(Windows 的 9 顆)必須是 no-op —— 一顆都不能少。
|
||
// 防止「修重複版本」誤傷正常安裝包。
|
||
func TestSelectLatestWheelPerPackage_CleanDirIsNoOp(t *testing.T) {
|
||
clean := []string{
|
||
"KneronPLUS-3.1.2-py3-none-any.whl",
|
||
"certifi-2026.2.25-py3-none-any.whl",
|
||
"charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl",
|
||
"idna-3.11-py3-none-any.whl",
|
||
"numpy-2.4.4-cp312-cp312-win_amd64.whl",
|
||
"opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl",
|
||
"pyusb-1.3.1-py3-none-any.whl",
|
||
"requests-2.33.1-py3-none-any.whl",
|
||
"urllib3-2.6.3-py3-none-any.whl",
|
||
}
|
||
paths, dropped := selectLatestWheelPerPackage("/w", clean)
|
||
if len(paths) != len(clean) {
|
||
t.Fatalf("乾淨目錄不得丟棄任何 wheel\ngot=%d want=%d", len(paths), len(clean))
|
||
}
|
||
if len(dropped) != 0 {
|
||
t.Fatalf("乾淨目錄不應有 dropped,got=%v", dropped)
|
||
}
|
||
}
|
||
|
||
// 無法解析檔名的 wheel 必須保留(寧可交給 pip 判斷,也不要靜默吞掉相依)。
|
||
func TestSelectLatestWheelPerPackage_KeepsUnparseableWheels(t *testing.T) {
|
||
paths, _ := selectLatestWheelPerPackage("/w", []string{"weird.whl", "numpy-2.5.1-py3-none-any.whl"})
|
||
if len(paths) != 2 {
|
||
t.Fatalf("無法解析的 wheel 應保留\ngot=%v", paths)
|
||
}
|
||
}
|
||
|
||
// 回傳路徑必須帶上 wheelsDir 前綴(pip 需要完整路徑)。
|
||
func TestSelectLatestWheelPerPackage_ReturnsFullPaths(t *testing.T) {
|
||
paths, _ := selectLatestWheelPerPackage("/tmp/wheels", []string{"numpy-2.5.1-py3-none-any.whl"})
|
||
want := filepath.Join("/tmp/wheels", "numpy-2.5.1-py3-none-any.whl")
|
||
if len(paths) != 1 || paths[0] != want {
|
||
t.Fatalf("got=%v want=[%s]", paths, want)
|
||
}
|
||
}
|
||
|
||
func assertNoDuplicatePackages(t *testing.T, paths []string) {
|
||
t.Helper()
|
||
seen := map[string]string{}
|
||
for _, p := range paths {
|
||
base := filepath.Base(p)
|
||
dist, _, ok := parseWheelName(base)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if prev, dup := seen[dist]; dup {
|
||
t.Fatalf("套件 %q 出現兩次(pip 會 ResolutionImpossible 秒退):%s 與 %s",
|
||
dist, prev, base)
|
||
}
|
||
seen[dist] = base
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 1b. 整合層:installBundledWheels 實際交給 pip 的參數必須已去重
|
||
//
|
||
// 單獨測 selectLatestWheelPerPackage 不夠 —— 若有人把 installBundledWheels 改回
|
||
// 「把 names 全部丟給 pip」,純函式測試仍會全綠,但使用者又會回到 2 秒失敗。
|
||
// 這條測試攔的就是那個回歸:直接檢查真正送進 pip 的 argv。
|
||
// ---------------------------------------------------------------------------
|
||
func TestInstallBundledWheels_PassesDedupedArgsToPip(t *testing.T) {
|
||
if runtime.GOOS == "windows" {
|
||
t.Skip("shell-script 假 python 在 Windows 上不適用")
|
||
}
|
||
runtimeDir := t.TempDir()
|
||
wheels := wheelsDirWith(t, realWorldDuplicatedWheels...)
|
||
|
||
// 假 python:把收到的 argv 逐行寫檔,pip 與 probe 都回成功。
|
||
argsLog := filepath.Join(runtimeDir, "args.txt")
|
||
fakePython := filepath.Join(runtimeDir, "python3")
|
||
writeFile(t, fakePython, "#!/bin/sh\nfor arg in \"$@\"; do echo \"$arg\" >> "+argsLog+"; done\nexit 0\n")
|
||
if err := os.Chmod(fakePython, 0o755); err != nil {
|
||
t.Fatalf("chmod: %v", err)
|
||
}
|
||
|
||
a := &App{}
|
||
if err := a.installBundledWheels(runtimeDir, fakePython, wheels); err != nil {
|
||
t.Fatalf("installBundledWheels: %v", err)
|
||
}
|
||
|
||
raw, err := os.ReadFile(argsLog)
|
||
if err != nil {
|
||
t.Fatalf("read args log: %v", err)
|
||
}
|
||
|
||
// 收集 argv 中所有 .whl 參數 —— 這就是 pip 真正要安裝的清單。
|
||
var passed []string
|
||
for _, line := range strings.Split(string(raw), "\n") {
|
||
if strings.HasSuffix(strings.TrimSpace(line), ".whl") {
|
||
passed = append(passed, strings.TrimSpace(line))
|
||
}
|
||
}
|
||
|
||
if len(passed) != 9 {
|
||
t.Fatalf("pip 應只收到 9 顆(每套件一顆),實際 %d 顆\n%v", len(passed), passed)
|
||
}
|
||
// 核心斷言:argv 內不得有同一套件的兩個版本,否則 pip ResolutionImpossible。
|
||
assertNoDuplicatePackages(t, passed)
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 2. 版本比較:數字段落必須以整數比大小
|
||
// ---------------------------------------------------------------------------
|
||
|
||
func TestCompareWheelVersions(t *testing.T) {
|
||
cases := []struct {
|
||
a, b string
|
||
want int
|
||
}{
|
||
{"2026.2.25", "2026.6.17", -1},
|
||
{"2026.6.17", "2026.7.22", -1},
|
||
{"2026.7.22", "2026.2.25", 1},
|
||
{"3.4.7", "3.4.9", -1},
|
||
{"2.4.4", "2.5.1", -1},
|
||
{"1.3.1", "1.3.1", 0},
|
||
// 字串比較會答錯的案例("10" < "9" 字典序)—— 必須用整數比
|
||
{"3.9", "3.10", -1},
|
||
{"2.0", "10.0", -1},
|
||
// 段數不同
|
||
{"4.13.0", "4.13.0.92", -1},
|
||
{"1.0", "1.0.0", -1},
|
||
}
|
||
for _, c := range cases {
|
||
if got := compareWheelVersions(c.a, c.b); got != c.want {
|
||
t.Errorf("compareWheelVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// 釘住「用整數而非字串比較」:numpy 2.9 vs 2.10,字串比較會誤選 2.9。
|
||
func TestSelectLatestWheelPerPackage_NumericNotLexicographic(t *testing.T) {
|
||
paths, _ := selectLatestWheelPerPackage("/w", []string{
|
||
"numpy-2.9.0-py3-none-any.whl",
|
||
"numpy-2.10.0-py3-none-any.whl",
|
||
})
|
||
if len(paths) != 1 {
|
||
t.Fatalf("應只留一顆,got=%v", paths)
|
||
}
|
||
if got := filepath.Base(paths[0]); got != "numpy-2.10.0-py3-none-any.whl" {
|
||
t.Fatalf("應選數值較大的 2.10.0(字串比較會誤選 2.9.0),got=%q", got)
|
||
}
|
||
}
|
||
|
||
func TestParseWheelName(t *testing.T) {
|
||
cases := []struct {
|
||
file, dist, ver string
|
||
ok bool
|
||
}{
|
||
{"numpy-2.4.4-cp312-cp312-win_amd64.whl", "numpy", "2.4.4", true},
|
||
{"KneronPLUS-3.1.2-py3-none-any.whl", "kneronplus", "3.1.2", true},
|
||
// 正規化:底線 → 連字號、轉小寫
|
||
{"opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", "opencv-python-headless", "4.13.0.92", true},
|
||
{"charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", "charset-normalizer", "3.4.7", true},
|
||
{"weird.whl", "", "", false},
|
||
{"", "", "", false},
|
||
}
|
||
for _, c := range cases {
|
||
dist, ver, ok := parseWheelName(c.file)
|
||
if ok != c.ok || dist != c.dist || ver != c.ver {
|
||
t.Errorf("parseWheelName(%q) = (%q, %q, %v), want (%q, %q, %v)",
|
||
c.file, dist, ver, ok, c.dist, c.ver, c.ok)
|
||
}
|
||
}
|
||
}
|
||
|
||
// PEP 503 正規化:底線 / 點 / 連字號視為等價,連續分隔符收斂。
|
||
func TestNormalizeDistName(t *testing.T) {
|
||
cases := []struct{ in, want string }{
|
||
{"opencv_python_headless", "opencv-python-headless"},
|
||
{"opencv-python-headless", "opencv-python-headless"},
|
||
{"opencv.python.headless", "opencv-python-headless"},
|
||
{"KneronPLUS", "kneronplus"},
|
||
{"charset__normalizer", "charset-normalizer"},
|
||
}
|
||
for _, c := range cases {
|
||
if got := normalizeDistName(c.in); got != c.want {
|
||
t.Errorf("normalizeDistName(%q) = %q, want %q", c.in, got, c.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
// 3. 放大器:Auto 模式失敗時必須保留 bundled 的真實原因
|
||
// ---------------------------------------------------------------------------
|
||
|
||
// 這是使用者「連續三輪看不到真因」的直接迴歸測試。
|
||
//
|
||
// 測試必須是確定性的,所以兩條路徑都強制失敗:
|
||
// - bundled:cwd 切到空目錄 + dataDir 空 → locateBundledPythonAssets 找不到資產
|
||
// - system:PATH 清空 → exec.LookPath 找不到任何 python
|
||
//
|
||
// 不這樣控制的話,開發機上 findSystemPython 會成功,Auto 直接回傳,測試被略過;
|
||
// 更糟的是 bundled 路徑會真的去解壓 tarball 建 venv(實測 32 秒)。
|
||
func TestEnsurePythonRuntime_AutoPreservesBundledFailureReason(t *testing.T) {
|
||
// PATH 清空 → system python 一定找不到。t.Setenv 會自動還原。
|
||
t.Setenv("PATH", "")
|
||
// AppImage 的資產提示也要清掉,否則可能指到真實 bundle。
|
||
t.Setenv("VISIONA_BUNDLE_LIB_DIR", "")
|
||
|
||
// cwd 切到空目錄 → payload/<os> 的開發模式 fallback 一定找不到。
|
||
// (不用 t.Chdir:那是 go1.24 才有的 API,本 module 是 go1.22。)
|
||
origWD, err := os.Getwd()
|
||
if err != nil {
|
||
t.Fatalf("getwd: %v", err)
|
||
}
|
||
if err := os.Chdir(t.TempDir()); err != nil {
|
||
t.Fatalf("chdir: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = os.Chdir(origWD) })
|
||
|
||
a := &App{dataDir: t.TempDir()}
|
||
|
||
_, _, runErr := a.ensurePythonRuntime(PythonModeAuto)
|
||
if runErr == nil {
|
||
t.Fatal("bundled 與 system 都不可用時,Auto 必須回 error")
|
||
}
|
||
|
||
msg := runErr.Error()
|
||
// 通用訊息仍在(維持既有語意)
|
||
if !strings.Contains(msg, "no python runtime available") {
|
||
t.Errorf("應保留原本的通用訊息,got=%q", msg)
|
||
}
|
||
// 關鍵:必須帶出 bundled 的具體原因,而不是只有通用訊息
|
||
if !strings.Contains(msg, "bundled 失敗") {
|
||
t.Errorf("最終 error 必須包含 bundled 失敗的原因(否則使用者看不到 pip 真錯)\ngot=%q", msg)
|
||
}
|
||
if !strings.Contains(msg, "system 失敗") {
|
||
t.Errorf("最終 error 必須包含 system 失敗的原因\ngot=%q", msg)
|
||
}
|
||
// bundled 的原始 error 必須可用 errors.Is/As 追溯(%w 而非 %v)
|
||
if !strings.Contains(msg, "bundled python assets not found") {
|
||
t.Errorf("bundled 的根本原因(找不到資產)應原樣出現在訊息中\ngot=%q", msg)
|
||
}
|
||
}
|
||
|
||
// 釘住 %w 包裝:bundled 的 error 鏈必須可被 errors.Is 追溯。
|
||
//
|
||
// 用一個 sentinel 驗證 fmt.Errorf 的包裝語意沒被改回 %v。
|
||
func TestAutoModeErrorWrapping_IsUnwrappable(t *testing.T) {
|
||
sentinel := errors.New("pip install wheels: ResolutionImpossible")
|
||
wrapped := fmt.Errorf(
|
||
"no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v",
|
||
sentinel, errors.New("no suitable python3"))
|
||
|
||
if !errors.Is(wrapped, sentinel) {
|
||
t.Fatal("bundled 的原始 error 必須能被 errors.Is 追溯(fmt.Errorf 要用 percent-w 包裝,不可退回 percent-v)")
|
||
}
|
||
}
|