From 9fe955d5ae17c85dc2c1e4871fdbebc215f281f9 Mon Sep 17 00:00:00 2001 From: nathaninline Date: Sat, 25 Jul 2026 12:29:47 +0200 Subject: [PATCH] backends precompiles : extraire les liens symboliques des archives Les archives llama.cpp macOS/Linux livrent les bibliotheques sous leur nom versionne (libllama-common.0.0.10107.dylib) PLUS un lien portant le nom recherche par l'editeur de liens (libllama-common.0.dylib). L'extracteur tar ignorait les entrees de type lien : backend installe mais llama-server mort-ne sur << dyld: Library not loaded >>. - extractArchive cree les liens (copie de repli quand os.Symlink echoue) - marqueur de format dans VERSION : une install faite par l'ancien extracteur est refaite au lieu d'etre declaree << deja a jour >> - test de non-regression + go test ajoute au job macOS de la CI --- .github/workflows/ci.yml | 4 ++ internal/jean/backend_prebuilt.go | 79 +++++++++++++++++++++++++- internal/jean/backend_prebuilt_test.go | 62 ++++++++++++++++++++ 3 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 internal/jean/backend_prebuilt_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a41c534..c4c6fe4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,10 @@ jobs: with: go-version-file: go.mod - run: go vet ./... + # Les tests tournent AUSSI ici : certains chemins ne s'exécutent que sur un + # vrai système de fichiers Unix (extraction des liens symboliques des + # archives llama.cpp, que Windows résout par une copie de repli). + - run: go test ./... - name: build macOS (CGO, arm64 + amd64) run: | CGO_ENABLED=1 GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/jean diff --git a/internal/jean/backend_prebuilt.go b/internal/jean/backend_prebuilt.go index ef877ea..97aefe1 100644 --- a/internal/jean/backend_prebuilt.go +++ b/internal/jean/backend_prebuilt.go @@ -57,6 +57,25 @@ func prebuiltVersion() (tag, cudaVer string) { return } +// prebuiltFormat identifie la façon dont l'archive a été extraite. « fmt2 » = +// extraction qui recrée les liens des archives (indispensable aux .dylib macOS +// et .so Linux). Une installation sans ce marqueur est réputée incomplète et +// sera refaite au lieu d'être déclarée « déjà à jour ». +const prebuiltFormat = "fmt2" + +// prebuiltVersionFormat lit le 3e champ du marqueur VERSION ("" si absent). +func prebuiltVersionFormat() string { + b, err := os.ReadFile(filepath.Join(prebuiltDir(), "VERSION")) + if err != nil { + return "" + } + f := strings.Fields(strings.TrimSpace(string(b))) + if len(f) > 2 { + return f[2] + } + return "" +} + // prebuiltServerBin localise llama-server(.exe) sous le dossier prebuilt // (l'arborescence interne des archives officielles varie : racine, build/bin…). func prebuiltServerBin() string { @@ -253,7 +272,11 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { } logf(fmt.Sprintf("release %s — variant retenu : %s", tag, label)) - if curTag == tag && prebuiltServerBin() != "" { + // Réinstaller à l'identique est inutile SAUF si l'extraction date d'une + // version de Jean qui ignorait les liens des archives (backend installé mais + // bibliothèques introuvables au lancement) : le marqueur de format force alors + // une ré-extraction propre au lieu d'un « déjà à jour » trompeur. + if curTag == tag && prebuiltVersionFormat() == prebuiltFormat && prebuiltServerBin() != "" { logf("déjà à jour (" + tag + ")") return prebuiltServerBin(), nil } @@ -301,7 +324,7 @@ func prebuiltInstall(logf, phasef func(string)) (string, error) { if runtime.GOOS != "windows" { _ = os.Chmod(bin, 0o755) } - if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+"\n"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, "VERSION"), []byte(tag+" "+cudaVer+" "+prebuiltFormat+"\n"), 0o644); err != nil { return "", err } logf("binaire installé : " + bin + " (release " + tag + ")") @@ -415,10 +438,13 @@ func extractArchive(path, dir string) error { } defer gz.Close() tr := tar.NewReader(gz) + // Les liens sont appliqués APRÈS coup : leur cible n'est pas forcément déjà + // extraite au moment où on croise l'entrée. + var links []archiveLink for { h, err := tr.Next() if err == io.EOF { - return nil + return applyLinks(links) } if err != nil { return err @@ -446,6 +472,53 @@ func extractArchive(path, dir string) error { } w.Close() _ = os.Chmod(p, os.FileMode(h.Mode)&0o777) + case tar.TypeSymlink, tar.TypeLink: + // Indispensable sur macOS : les archives llama.cpp livrent les .dylib + // sous leur nom versionné (libllama-common.0.0.10107.dylib) PLUS un lien + // portant le nom que cherche le binaire (libllama-common.0.dylib). Ignorer + // ces entrées donnait un backend installé mais impossible à lancer : + // « dyld: Library not loaded: @rpath/libllama-common.0.dylib ». + target := h.Linkname + if h.Typeflag == tar.TypeLink { + // Lien dur : la cible est un chemin dans l'archive, pas un chemin relatif. + t, err := safe(h.Linkname) + if err != nil { + return err + } + target = t + } + links = append(links, archiveLink{path: p, target: target}) } } } + +// archiveLink : un lien (symbolique ou dur) relevé dans une archive. +type archiveLink struct{ path, target string } + +// applyLinks crée les liens relevés pendant l'extraction. Sur les systèmes où la +// création de liens symboliques est refusée (Windows sans mode développeur), on +// copie le fichier cible : moins élégant, mais fonctionnel. +func applyLinks(links []archiveLink) error { + for _, l := range links { + _ = os.Remove(l.path) + if err := os.MkdirAll(filepath.Dir(l.path), 0o755); err != nil { + return err + } + if err := os.Symlink(l.target, l.path); err == nil { + continue + } + // Repli par copie. La cible peut être relative au dossier du lien. + src := l.target + if !filepath.IsAbs(src) { + src = filepath.Join(filepath.Dir(l.path), l.target) + } + data, err := os.ReadFile(src) + if err != nil { + continue // cible absente de l'archive : on n'échoue pas l'installation pour ça + } + if err := os.WriteFile(l.path, data, 0o755); err != nil { + return err + } + } + return nil +} diff --git a/internal/jean/backend_prebuilt_test.go b/internal/jean/backend_prebuilt_test.go new file mode 100644 index 0000000..e513cd6 --- /dev/null +++ b/internal/jean/backend_prebuilt_test.go @@ -0,0 +1,62 @@ +package jean + +import ( + "archive/tar" + "compress/gzip" + "os" + "path/filepath" + "testing" +) + +// TestExtractArchiveSymlink : les archives macOS/Linux de llama.cpp livrent les +// bibliothèques sous leur nom versionné (libllama-common.0.0.10107.dylib) plus +// un LIEN SYMBOLIQUE portant le nom recherché par l'éditeur de liens +// (libllama-common.0.dylib). L'extracteur ignorait ces entrées : le backend +// s'installait mais llama-server mourait sur « Library not loaded ». +func TestExtractArchiveSymlink(t *testing.T) { + dir := t.TempDir() + archive := filepath.Join(dir, "backend.tar.gz") + + f, err := os.Create(archive) + if err != nil { + t.Fatal(err) + } + gz := gzip.NewWriter(f) + tw := tar.NewWriter(gz) + body := []byte("faux contenu de bibliothèque") + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeReg, Name: "b/libllama-common.0.0.1.dylib", + Mode: 0o644, Size: int64(len(body)), + }); err != nil { + t.Fatal(err) + } + if _, err := tw.Write(body); err != nil { + t.Fatal(err) + } + if err := tw.WriteHeader(&tar.Header{ + Typeflag: tar.TypeSymlink, Name: "b/libllama-common.0.dylib", + Linkname: "libllama-common.0.0.1.dylib", Mode: 0o777, + }); err != nil { + t.Fatal(err) + } + for _, c := range []func() error{tw.Close, gz.Close, f.Close} { + if err := c(); err != nil { + t.Fatal(err) + } + } + + out := filepath.Join(dir, "out") + if err := extractArchive(archive, out); err != nil { + t.Fatalf("extractArchive: %v", err) + } + + // Le lien doit être RÉSOLVABLE : symlink là où c'est permis, copie sinon + // (Windows sans mode développeur). Dans les deux cas os.ReadFile réussit. + got, err := os.ReadFile(filepath.Join(out, "b", "libllama-common.0.dylib")) + if err != nil { + t.Fatalf("lien non extrait : %v", err) + } + if string(got) != string(body) { + t.Fatalf("contenu résolu = %q, attendu %q", got, body) + } +}