mirror of
https://github.com/mudler/LocalAI.git
synced 2026-09-27 16:45:17 -04:00
fix(vram): contain malformed GGUF metadata (#11374)
Recover parser panics at metadata boundaries, skip unneeded remote arrays, and use the parser's overflow-hardened release. Keep detached gallery workers and CrispASR probes from terminating their processes on malformed GGUF input. Disable startup warming in the provided Compose files as an operational fallback. Assisted-by: Codex:gpt-5 Signed-off-by: Richard Palethorpe <io@richiejp.com>
This commit is contained in:
1 parent
1271b97a46
commit
f8d3f31594
12 files changed
+250
-12
No files matched your search
@@ -67,7 +67,16 @@ const defaultTTSSampleRate = 24000
|
||||
// resampling, so the WAV header must match it. Returns ok=false for non-piper
|
||||
// models (key absent) or an unreadable file, letting the caller fall back to
|
||||
// defaultTTSSampleRate.
|
||||
func piperSampleRate(modelPath string) (int, bool) {
|
||||
func piperSampleRate(modelPath string) (rate int, ok bool) {
|
||||
// A malformed metadata length can make gguf-parser-go panic before it can
|
||||
// return an error. Keep a bad voice file from crash-looping the backend.
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
rate = 0
|
||||
ok = false
|
||||
}
|
||||
}()
|
||||
|
||||
// Only scalar architecture keys are read, so skip the large array metadata
|
||||
// (phoneme map) and mmap the header - same rationale as pkg/vram's reader.
|
||||
f, err := gguf.ParseGGUFFile(modelPath, gguf.UseMMap(), gguf.SkipLargeMetadata())
|
||||
@@ -78,7 +87,7 @@ func piperSampleRate(modelPath string) (int, bool) {
|
||||
if !ok || kv.ValueType != gguf.GGUFMetadataValueTypeUint32 {
|
||||
return 0, false
|
||||
}
|
||||
rate := int(kv.ValueUint32())
|
||||
rate = int(kv.ValueUint32())
|
||||
if rate <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -102,6 +103,24 @@ var _ = Describe("piper sample rate", func() {
|
||||
_, ok := piperSampleRate(p)
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
|
||||
It("returns ok=false instead of panicking on a malformed string length", func() {
|
||||
p := filepath.Join(GinkgoT().TempDir(), "malformed.gguf")
|
||||
var b bytes.Buffer
|
||||
b.WriteString("GGUF")
|
||||
Expect(binary.Write(&b, binary.LittleEndian, uint32(3))).To(Succeed())
|
||||
Expect(binary.Write(&b, binary.LittleEndian, uint64(0))).To(Succeed())
|
||||
Expect(binary.Write(&b, binary.LittleEndian, uint64(1))).To(Succeed())
|
||||
key := "general.name"
|
||||
Expect(binary.Write(&b, binary.LittleEndian, uint64(len(key)))).To(Succeed())
|
||||
b.WriteString(key)
|
||||
Expect(binary.Write(&b, binary.LittleEndian, ggufTypeString)).To(Succeed())
|
||||
Expect(binary.Write(&b, binary.LittleEndian, uint64(math.MaxInt64))).To(Succeed())
|
||||
Expect(os.WriteFile(p, b.Bytes(), 0o644)).To(Succeed())
|
||||
|
||||
_, ok := piperSampleRate(p)
|
||||
Expect(ok).To(BeFalse())
|
||||
})
|
||||
})
|
||||
|
||||
// End-to-end through the built .so. Gated on CRISPASR_PIPER_MODEL_PATH (a
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/pkg/concurrency"
|
||||
"github.com/mudler/LocalAI/pkg/system"
|
||||
"github.com/mudler/LocalAI/pkg/vram"
|
||||
"github.com/mudler/xlog"
|
||||
@@ -101,7 +102,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
concurrency.SafeGo(func() {
|
||||
started := time.Now()
|
||||
|
||||
models, err := AvailableGalleryModelsCached(galleries, systemState)
|
||||
@@ -131,7 +132,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
|
||||
|
||||
for i := 0; i < cfg.Concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
concurrency.SafeGo(func() {
|
||||
defer wg.Done()
|
||||
for m := range cursor {
|
||||
// Per entry, not for the run: one unreachable weight file
|
||||
@@ -164,7 +165,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
|
||||
|
||||
cancel()
|
||||
}
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
feed:
|
||||
@@ -183,7 +184,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
|
||||
return
|
||||
}
|
||||
xlog.Info("gallery caches warmed", "estimates", warmed, "variants", warmedVariants, "of", len(models), "took", time.Since(started).Round(time.Second))
|
||||
}()
|
||||
})
|
||||
}
|
||||
|
||||
// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment,
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
package gallery_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
gguf "github.com/gpustack/gguf-parser-go"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
"gopkg.in/yaml.v3"
|
||||
|
||||
"github.com/mudler/LocalAI/core/config"
|
||||
"github.com/mudler/LocalAI/core/gallery"
|
||||
@@ -57,6 +66,46 @@ var _ = Describe("VRAM estimate warm-up", func() {
|
||||
Consistently(func() bool { return true }, "100ms").Should(BeTrue())
|
||||
})
|
||||
|
||||
It("does not crash the server when remote GGUF metadata is malformed", func() {
|
||||
payload := warmMalformedGGUF()
|
||||
requested := make(chan struct{})
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-requested:
|
||||
default:
|
||||
close(requested)
|
||||
}
|
||||
http.ServeContent(w, r, "model.gguf", time.Time{}, bytes.NewReader(payload))
|
||||
}))
|
||||
DeferCleanup(server.Close)
|
||||
|
||||
galleryPath := filepath.Join(state.Model.ModelsPath, "malformed-gallery.yaml")
|
||||
index, err := yaml.Marshal([]gallery.GalleryModel{{Metadata: gallery.Metadata{
|
||||
Name: "malformed-gguf",
|
||||
AdditionalFiles: []gallery.File{{
|
||||
Filename: "model.gguf",
|
||||
URI: server.URL + "/model.gguf",
|
||||
}},
|
||||
}}})
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(os.WriteFile(galleryPath, index, 0600)).To(Succeed())
|
||||
|
||||
cfg := gallery.DefaultEstimateWarmConfig
|
||||
cfg.Limit = 1
|
||||
cfg.Concurrency = 1
|
||||
cfg.Contexts = []uint32{8192}
|
||||
gallery.WarmEstimateCache(context.Background(), []config.Gallery{{
|
||||
Name: "malformed",
|
||||
URL: "file://" + galleryPath,
|
||||
}}, state, cfg)
|
||||
|
||||
Eventually(requested, "2s").Should(BeClosed())
|
||||
// The warm-up is detached. Give its parser time to consume the response;
|
||||
// before the recovery boundary, that goroutine panicked and killed the
|
||||
// entire test process (and the LocalAI server in production).
|
||||
Consistently(func() bool { return true }, "300ms").Should(BeTrue())
|
||||
})
|
||||
|
||||
Describe("configuration from the environment", func() {
|
||||
AfterEach(func() {
|
||||
os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT")
|
||||
@@ -113,3 +162,19 @@ var _ = Describe("VRAM estimate warm-up", func() {
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
func warmMalformedGGUF() []byte {
|
||||
payload := make([]byte, 0, 128)
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMagicGGUFLe))
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFVersionV3))
|
||||
payload = binary.LittleEndian.AppendUint64(payload, 0)
|
||||
payload = binary.LittleEndian.AppendUint64(payload, 1)
|
||||
key := "tokenizer.ggml.tokens"
|
||||
payload = binary.LittleEndian.AppendUint64(payload, uint64(len(key)))
|
||||
payload = append(payload, key...)
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeArray))
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeString))
|
||||
payload = binary.LittleEndian.AppendUint64(payload, 1)
|
||||
payload = binary.LittleEndian.AppendUint64(payload, math.MaxUint64)
|
||||
return payload
|
||||
}
|
||||
@@ -401,7 +401,10 @@ func maybeApplyMTPDefaults(modelConfig *config.ModelConfig, details Details, cfg
|
||||
}
|
||||
}()
|
||||
|
||||
f, err := gguf.ParseGGUFFileRemote(ctx, probeURL)
|
||||
// MTP markers are architecture scalars. Avoid allocating tokenizer and
|
||||
// other large arrays from an untrusted remote header; panic recovery cannot
|
||||
// contain a fatal out-of-memory condition.
|
||||
f, err := gguf.ParseGGUFFileRemote(ctx, probeURL, gguf.SkipLargeMetadata())
|
||||
if err != nil {
|
||||
xlog.Debug("[mtp-importer] failed to read remote GGUF header for MTP detection", "uri", probeURL, "error", err)
|
||||
return
|
||||
|
||||
@@ -74,6 +74,9 @@ services:
|
||||
GODEBUG: "netdns=go"
|
||||
# Paths
|
||||
MODELS_PATH: /models
|
||||
# Avoid probing remote gallery GGUF metadata during container startup.
|
||||
# Remove this line or set a positive limit to opt back into cache warming.
|
||||
LOCALAI_VRAM_WARM_LIMIT: "0"
|
||||
volumes:
|
||||
- frontend_models:/models
|
||||
- frontend_data:/data
|
||||
|
||||
@@ -18,6 +18,9 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- MODELS_PATH=/models
|
||||
# Avoid probing remote gallery GGUF metadata during container startup.
|
||||
# Remove this line or set a positive limit to opt back into cache warming.
|
||||
- LOCALAI_VRAM_WARM_LIMIT=0
|
||||
# - DEBUG=true
|
||||
## Agents (LocalAGI) - https://localai.io/features/agents/
|
||||
# - LOCALAI_DISABLE_AGENTS=false
|
||||
|
||||
@@ -477,6 +477,11 @@ then on.
|
||||
| `LOCALAI_VRAM_WARM_LIMIT` | `300` | How many gallery entries to warm at startup, estimates and variants alike. Set to `0` to disable the warm-up entirely. |
|
||||
| `LOCALAI_VRAM_WARM_CONCURRENCY` | `4` | How many estimates to run at once. |
|
||||
|
||||
The provided Docker Compose configurations set `LOCALAI_VRAM_WARM_LIMIT=0`
|
||||
as a defensive default, so container startup does not probe remote GGUF files.
|
||||
Remove that override or set it to a positive number to opt into background
|
||||
warming.
|
||||
|
||||
```bash
|
||||
# Air-gapped, or you would rather not make the requests at all
|
||||
LOCALAI_VRAM_WARM_LIMIT=0 local-ai run
|
||||
|
||||
@@ -24,7 +24,7 @@ require (
|
||||
github.com/gofrs/flock v0.13.0
|
||||
github.com/google/go-containerregistry v0.21.6
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gpustack/gguf-parser-go v0.24.0
|
||||
github.com/gpustack/gguf-parser-go v0.25.0
|
||||
github.com/hpcloud/tail v1.0.0
|
||||
github.com/ipfs/go-log v1.0.5
|
||||
github.com/jaypipes/ghw v0.24.0
|
||||
|
||||
@@ -666,8 +666,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
|
||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
|
||||
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
|
||||
github.com/gpustack/gguf-parser-go v0.24.0 h1:tdJceXYp9e5RhE9RwVYIuUpir72Jz2D68NEtDXkKCKc=
|
||||
github.com/gpustack/gguf-parser-go v0.24.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
|
||||
github.com/gpustack/gguf-parser-go v0.25.0 h1:1AMBhMKtI24nTtn588Bq53FqNiOvEw1x9Nb4HbRrThs=
|
||||
github.com/gpustack/gguf-parser-go v0.25.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
|
||||
|
||||
+17
-2
@@ -2,6 +2,7 @@ package vram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
gguf "github.com/gpustack/gguf-parser-go"
|
||||
@@ -10,7 +11,18 @@ import (
|
||||
|
||||
type defaultGGUFReader struct{}
|
||||
|
||||
func (defaultGGUFReader) ReadMetadata(ctx context.Context, uri string) (*GGUFMeta, error) {
|
||||
func (defaultGGUFReader) ReadMetadata(ctx context.Context, uri string) (meta *GGUFMeta, err error) {
|
||||
// gguf-parser-go parses lengths supplied by the file and has historically
|
||||
// panicked on values that cannot fit in a Go slice. Metadata can come from
|
||||
// an untrusted remote host, and this reader is also used by a background
|
||||
// gallery worker, where an escaped panic would terminate the whole server.
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
meta = nil
|
||||
err = fmt.Errorf("read GGUF metadata: parser panic: %v", recovered)
|
||||
}
|
||||
}()
|
||||
|
||||
u := downloader.URI(uri)
|
||||
urlStr := u.ResolveURL()
|
||||
|
||||
@@ -28,7 +40,10 @@ func (defaultGGUFReader) ReadMetadata(ctx context.Context, uri string) (*GGUFMet
|
||||
if !u.LooksLikeHTTPURL() {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := gguf.ParseGGUFFileRemote(ctx, urlStr)
|
||||
// The estimator only consumes architecture scalars. Tokenizer arrays can
|
||||
// be very large and are unnecessary here, so avoid downloading or
|
||||
// allocating them for remote files just as the local path does above.
|
||||
f, err := gguf.ParseGGUFFileRemote(ctx, urlStr, gguf.SkipLargeMetadata())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
package vram_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"time"
|
||||
|
||||
gguf "github.com/gpustack/gguf-parser-go"
|
||||
"github.com/mudler/LocalAI/pkg/vram"
|
||||
. "github.com/onsi/ginkgo/v2"
|
||||
. "github.com/onsi/gomega"
|
||||
)
|
||||
|
||||
var _ = Describe("DefaultGGUFReader", func() {
|
||||
It("reads architecture scalars from a valid remote GGUF", func() {
|
||||
server := serveGGUF(validRemoteGGUF())
|
||||
|
||||
meta, err := vram.DefaultGGUFReader().ReadMetadata(context.Background(), server.URL+"/model.gguf")
|
||||
|
||||
Expect(err).NotTo(HaveOccurred())
|
||||
Expect(meta).To(Equal(&vram.GGUFMeta{
|
||||
BlockCount: 32,
|
||||
EmbeddingLength: 4096,
|
||||
HeadCount: 32,
|
||||
HeadCountKV: 8,
|
||||
MaximumContextLength: 8192,
|
||||
}))
|
||||
})
|
||||
|
||||
It("rejects an overflowing tokenizer array without allocating it", func() {
|
||||
server := serveGGUF(malformedGGUFArray(math.MaxUint64))
|
||||
|
||||
_, err := vram.DefaultGGUFReader().ReadMetadata(context.Background(), server.URL+"/model.gguf")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).NotTo(ContainSubstring("parser panic"),
|
||||
"large tokenizer metadata should be skipped with a bounds error")
|
||||
})
|
||||
|
||||
It("converts a parser panic from malformed string metadata to an error", func() {
|
||||
server := serveGGUF(malformedGGUFString(uint64(math.MaxInt64)))
|
||||
|
||||
_, err := vram.DefaultGGUFReader().ReadMetadata(context.Background(), server.URL+"/model.gguf")
|
||||
|
||||
Expect(err).To(HaveOccurred())
|
||||
Expect(err.Error()).To(ContainSubstring("parser panic"))
|
||||
})
|
||||
})
|
||||
|
||||
func serveGGUF(payload []byte) *httptest.Server {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeContent(w, r, "model.gguf", time.Time{}, bytes.NewReader(payload))
|
||||
}))
|
||||
DeferCleanup(server.Close)
|
||||
return server
|
||||
}
|
||||
|
||||
func malformedGGUFString(length uint64) []byte {
|
||||
payload := ggufHeader(1)
|
||||
payload = appendGGUFString(payload, "general.name")
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeString))
|
||||
payload = binary.LittleEndian.AppendUint64(payload, length)
|
||||
return payload
|
||||
}
|
||||
|
||||
func validRemoteGGUF() []byte {
|
||||
payload := ggufHeader(6)
|
||||
payload = appendGGUFStringValue(payload, "general.architecture", "llama")
|
||||
payload = appendGGUFUint32(payload, "llama.block_count", 32)
|
||||
payload = appendGGUFUint32(payload, "llama.embedding_length", 4096)
|
||||
payload = appendGGUFUint32(payload, "llama.attention.head_count", 32)
|
||||
payload = appendGGUFUint32(payload, "llama.attention.head_count_kv", 8)
|
||||
payload = appendGGUFUint32(payload, "llama.context_length", 8192)
|
||||
return payload
|
||||
}
|
||||
|
||||
func malformedGGUFArray(itemLength uint64) []byte {
|
||||
payload := ggufHeader(1)
|
||||
payload = appendGGUFString(payload, "tokenizer.ggml.tokens")
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeArray))
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeString))
|
||||
payload = binary.LittleEndian.AppendUint64(payload, 1)
|
||||
payload = binary.LittleEndian.AppendUint64(payload, itemLength)
|
||||
return payload
|
||||
}
|
||||
|
||||
func ggufHeader(metadataCount uint64) []byte {
|
||||
payload := make([]byte, 0, 128)
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMagicGGUFLe))
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFVersionV3))
|
||||
payload = binary.LittleEndian.AppendUint64(payload, 0)
|
||||
payload = binary.LittleEndian.AppendUint64(payload, metadataCount)
|
||||
return payload
|
||||
}
|
||||
|
||||
func appendGGUFString(payload []byte, value string) []byte {
|
||||
payload = binary.LittleEndian.AppendUint64(payload, uint64(len(value)))
|
||||
return append(payload, value...)
|
||||
}
|
||||
|
||||
func appendGGUFStringValue(payload []byte, key, value string) []byte {
|
||||
payload = appendGGUFString(payload, key)
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeString))
|
||||
return appendGGUFString(payload, value)
|
||||
}
|
||||
|
||||
func appendGGUFUint32(payload []byte, key string, value uint32) []byte {
|
||||
payload = appendGGUFString(payload, key)
|
||||
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeUint32))
|
||||
return binary.LittleEndian.AppendUint32(payload, value)
|
||||
}
|
||||
Reference in new issue
Block a user