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
This commit is contained in:
nathaninline committed 2026-07-25 12:29:47 +02:00
1 parent 43da4b9197
commit 9fe955d5ae
3 files changed
+142 -3

No files matched your search

+4
View File
@@ -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
+76 -3
View File
@@ -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
}
+62
View File
@@ -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)
}
}