Skip to content

Commit f8d3f31

Browse files
authored
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>
1 parent 1271b97 commit f8d3f31

12 files changed

Lines changed: 250 additions & 12 deletions

File tree

backend/go/crispasr/gocrispasr.go

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,16 @@ const defaultTTSSampleRate = 24000
6767
// resampling, so the WAV header must match it. Returns ok=false for non-piper
6868
// models (key absent) or an unreadable file, letting the caller fall back to
6969
// defaultTTSSampleRate.
70-
func piperSampleRate(modelPath string) (int, bool) {
70+
func piperSampleRate(modelPath string) (rate int, ok bool) {
71+
// A malformed metadata length can make gguf-parser-go panic before it can
72+
// return an error. Keep a bad voice file from crash-looping the backend.
73+
defer func() {
74+
if recover() != nil {
75+
rate = 0
76+
ok = false
77+
}
78+
}()
79+
7180
// Only scalar architecture keys are read, so skip the large array metadata
7281
// (phoneme map) and mmap the header - same rationale as pkg/vram's reader.
7382
f, err := gguf.ParseGGUFFile(modelPath, gguf.UseMMap(), gguf.SkipLargeMetadata())
@@ -78,7 +87,7 @@ func piperSampleRate(modelPath string) (int, bool) {
7887
if !ok || kv.ValueType != gguf.GGUFMetadataValueTypeUint32 {
7988
return 0, false
8089
}
81-
rate := int(kv.ValueUint32())
90+
rate = int(kv.ValueUint32())
8291
if rate <= 0 {
8392
return 0, false
8493
}

backend/go/crispasr/gocrispasr_samplerate_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package main
33
import (
44
"bytes"
55
"encoding/binary"
6+
"math"
67
"os"
78
"path/filepath"
89

@@ -102,6 +103,24 @@ var _ = Describe("piper sample rate", func() {
102103
_, ok := piperSampleRate(p)
103104
Expect(ok).To(BeFalse())
104105
})
106+
107+
It("returns ok=false instead of panicking on a malformed string length", func() {
108+
p := filepath.Join(GinkgoT().TempDir(), "malformed.gguf")
109+
var b bytes.Buffer
110+
b.WriteString("GGUF")
111+
Expect(binary.Write(&b, binary.LittleEndian, uint32(3))).To(Succeed())
112+
Expect(binary.Write(&b, binary.LittleEndian, uint64(0))).To(Succeed())
113+
Expect(binary.Write(&b, binary.LittleEndian, uint64(1))).To(Succeed())
114+
key := "general.name"
115+
Expect(binary.Write(&b, binary.LittleEndian, uint64(len(key)))).To(Succeed())
116+
b.WriteString(key)
117+
Expect(binary.Write(&b, binary.LittleEndian, ggufTypeString)).To(Succeed())
118+
Expect(binary.Write(&b, binary.LittleEndian, uint64(math.MaxInt64))).To(Succeed())
119+
Expect(os.WriteFile(p, b.Bytes(), 0o644)).To(Succeed())
120+
121+
_, ok := piperSampleRate(p)
122+
Expect(ok).To(BeFalse())
123+
})
105124
})
106125

107126
// End-to-end through the built .so. Gated on CRISPASR_PIPER_MODEL_PATH (a

core/gallery/estimate_warm.go

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"time"
1010

1111
"github.com/mudler/LocalAI/core/config"
12+
"github.com/mudler/LocalAI/pkg/concurrency"
1213
"github.com/mudler/LocalAI/pkg/system"
1314
"github.com/mudler/LocalAI/pkg/vram"
1415
"github.com/mudler/xlog"
@@ -101,7 +102,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
101102
return
102103
}
103104

104-
go func() {
105+
concurrency.SafeGo(func() {
105106
started := time.Now()
106107

107108
models, err := AvailableGalleryModelsCached(galleries, systemState)
@@ -131,7 +132,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
131132

132133
for i := 0; i < cfg.Concurrency; i++ {
133134
wg.Add(1)
134-
go func() {
135+
concurrency.SafeGo(func() {
135136
defer wg.Done()
136137
for m := range cursor {
137138
// Per entry, not for the run: one unreachable weight file
@@ -164,7 +165,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
164165

165166
cancel()
166167
}
167-
}()
168+
})
168169
}
169170

170171
feed:
@@ -183,7 +184,7 @@ func WarmEstimateCache(ctx context.Context, galleries []config.Gallery, systemSt
183184
return
184185
}
185186
xlog.Info("gallery caches warmed", "estimates", warmed, "variants", warmedVariants, "of", len(models), "took", time.Since(started).Round(time.Second))
186-
}()
187+
})
187188
}
188189

189190
// EstimateWarmConfigFromEnv reads the warm-up bounds from the environment,

core/gallery/estimate_warm_test.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,20 @@
11
package gallery_test
22

33
import (
4+
"bytes"
45
"context"
6+
"encoding/binary"
7+
"math"
8+
"net/http"
9+
"net/http/httptest"
510
"os"
11+
"path/filepath"
12+
"time"
613

14+
gguf "github.com/gpustack/gguf-parser-go"
715
. "github.com/onsi/ginkgo/v2"
816
. "github.com/onsi/gomega"
17+
"gopkg.in/yaml.v3"
918

1019
"github.com/mudler/LocalAI/core/config"
1120
"github.com/mudler/LocalAI/core/gallery"
@@ -57,6 +66,46 @@ var _ = Describe("VRAM estimate warm-up", func() {
5766
Consistently(func() bool { return true }, "100ms").Should(BeTrue())
5867
})
5968

69+
It("does not crash the server when remote GGUF metadata is malformed", func() {
70+
payload := warmMalformedGGUF()
71+
requested := make(chan struct{})
72+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
73+
select {
74+
case <-requested:
75+
default:
76+
close(requested)
77+
}
78+
http.ServeContent(w, r, "model.gguf", time.Time{}, bytes.NewReader(payload))
79+
}))
80+
DeferCleanup(server.Close)
81+
82+
galleryPath := filepath.Join(state.Model.ModelsPath, "malformed-gallery.yaml")
83+
index, err := yaml.Marshal([]gallery.GalleryModel{{Metadata: gallery.Metadata{
84+
Name: "malformed-gguf",
85+
AdditionalFiles: []gallery.File{{
86+
Filename: "model.gguf",
87+
URI: server.URL + "/model.gguf",
88+
}},
89+
}}})
90+
Expect(err).NotTo(HaveOccurred())
91+
Expect(os.WriteFile(galleryPath, index, 0600)).To(Succeed())
92+
93+
cfg := gallery.DefaultEstimateWarmConfig
94+
cfg.Limit = 1
95+
cfg.Concurrency = 1
96+
cfg.Contexts = []uint32{8192}
97+
gallery.WarmEstimateCache(context.Background(), []config.Gallery{{
98+
Name: "malformed",
99+
URL: "file://" + galleryPath,
100+
}}, state, cfg)
101+
102+
Eventually(requested, "2s").Should(BeClosed())
103+
// The warm-up is detached. Give its parser time to consume the response;
104+
// before the recovery boundary, that goroutine panicked and killed the
105+
// entire test process (and the LocalAI server in production).
106+
Consistently(func() bool { return true }, "300ms").Should(BeTrue())
107+
})
108+
60109
Describe("configuration from the environment", func() {
61110
AfterEach(func() {
62111
os.Unsetenv("LOCALAI_VRAM_WARM_LIMIT")
@@ -113,3 +162,19 @@ var _ = Describe("VRAM estimate warm-up", func() {
113162
})
114163

115164
})
165+
166+
func warmMalformedGGUF() []byte {
167+
payload := make([]byte, 0, 128)
168+
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMagicGGUFLe))
169+
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFVersionV3))
170+
payload = binary.LittleEndian.AppendUint64(payload, 0)
171+
payload = binary.LittleEndian.AppendUint64(payload, 1)
172+
key := "tokenizer.ggml.tokens"
173+
payload = binary.LittleEndian.AppendUint64(payload, uint64(len(key)))
174+
payload = append(payload, key...)
175+
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeArray))
176+
payload = binary.LittleEndian.AppendUint32(payload, uint32(gguf.GGUFMetadataValueTypeString))
177+
payload = binary.LittleEndian.AppendUint64(payload, 1)
178+
payload = binary.LittleEndian.AppendUint64(payload, math.MaxUint64)
179+
return payload
180+
}

core/gallery/importers/llama-cpp.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,10 @@ func maybeApplyMTPDefaults(modelConfig *config.ModelConfig, details Details, cfg
401401
}
402402
}()
403403

404-
f, err := gguf.ParseGGUFFileRemote(ctx, probeURL)
404+
// MTP markers are architecture scalars. Avoid allocating tokenizer and
405+
// other large arrays from an untrusted remote header; panic recovery cannot
406+
// contain a fatal out-of-memory condition.
407+
f, err := gguf.ParseGGUFFileRemote(ctx, probeURL, gguf.SkipLargeMetadata())
405408
if err != nil {
406409
xlog.Debug("[mtp-importer] failed to read remote GGUF header for MTP detection", "uri", probeURL, "error", err)
407410
return

docker-compose.distributed.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,9 @@ services:
7474
GODEBUG: "netdns=go"
7575
# Paths
7676
MODELS_PATH: /models
77+
# Avoid probing remote gallery GGUF metadata during container startup.
78+
# Remove this line or set a positive limit to opt back into cache warming.
79+
LOCALAI_VRAM_WARM_LIMIT: "0"
7780
volumes:
7881
- frontend_models:/models
7982
- frontend_data:/data

docker-compose.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ services:
1818
- .env
1919
environment:
2020
- MODELS_PATH=/models
21+
# Avoid probing remote gallery GGUF metadata during container startup.
22+
# Remove this line or set a positive limit to opt back into cache warming.
23+
- LOCALAI_VRAM_WARM_LIMIT=0
2124
# - DEBUG=true
2225
## Agents (LocalAGI) - https://localai.io/features/agents/
2326
# - LOCALAI_DISABLE_AGENTS=false

docs/content/advanced/vram-management.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -477,6 +477,11 @@ then on.
477477
| `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. |
478478
| `LOCALAI_VRAM_WARM_CONCURRENCY` | `4` | How many estimates to run at once. |
479479

480+
The provided Docker Compose configurations set `LOCALAI_VRAM_WARM_LIMIT=0`
481+
as a defensive default, so container startup does not probe remote GGUF files.
482+
Remove that override or set it to a positive number to opt into background
483+
warming.
484+
480485
```bash
481486
# Air-gapped, or you would rather not make the requests at all
482487
LOCALAI_VRAM_WARM_LIMIT=0 local-ai run

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ require (
2424
github.com/gofrs/flock v0.13.0
2525
github.com/google/go-containerregistry v0.21.6
2626
github.com/google/uuid v1.6.0
27-
github.com/gpustack/gguf-parser-go v0.24.0
27+
github.com/gpustack/gguf-parser-go v0.25.0
2828
github.com/hpcloud/tail v1.0.0
2929
github.com/ipfs/go-log v1.0.5
3030
github.com/jaypipes/ghw v0.24.0

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,8 +666,8 @@ github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A
666666
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
667667
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
668668
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
669-
github.com/gpustack/gguf-parser-go v0.24.0 h1:tdJceXYp9e5RhE9RwVYIuUpir72Jz2D68NEtDXkKCKc=
670-
github.com/gpustack/gguf-parser-go v0.24.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
669+
github.com/gpustack/gguf-parser-go v0.25.0 h1:1AMBhMKtI24nTtn588Bq53FqNiOvEw1x9Nb4HbRrThs=
670+
github.com/gpustack/gguf-parser-go v0.25.0/go.mod h1:y4TwTtDqFWTK+xvprOjRUh+dowgU2TKCX37vRKvGiZ0=
671671
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI=
672672
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8=
673673
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=

0 commit comments

Comments
 (0)