Files
LocalAI/core/gallery/model_artifacts.go
mudler's LocalAI [bot] 2f33ad6669 fix(modelartifacts): treat CIFS EACCES as lock contention, not failure (#10986)
flock(2) on CIFS/SMB returns EACCES when another client holds the lock:
the kernel maps STATUS_LOCK_NOT_GRANTED and STATUS_FILE_LOCK_CONFLICT to
-EACCES and never produces EWOULDBLOCK on that path. gofrs/flock only
recognises EWOULDBLOCK as contention, so TryLockContext returned a bare
"permission denied" and Ensure aborted. Both replicas then fell back to
legacy loading, which makes the worker download the whole repo in-band
inside LoadModel and blow the remote-load deadline.

Replace TryLockContext with an explicit wait loop over a new Locker
interface, classifying EWOULDBLOCK/EAGAIN/EACCES/EBUSY as contention.
EACCES is ambiguous at the syscall boundary but not here: the lock file
is already open O_CREATE|O_RDWR, so a real permission problem would have
failed the open with an *fs.PathError, and flock(2) documents no EACCES
on Linux at all. The wait is bounded (DefaultLockWait, overridable via
WithLockWait), so even a misclassification degrades to a delay. On
timeout the committed result is re-checked before reporting the new
ErrLockContended, so a peer that finished the work still wins.

Locker also exists so the contention path is testable without a network
filesystem: nothing in CI can make flock(2) return EACCES on demand.

Raise the fallback to error for a managedArtifactBackends backend, via a
shared config.LogArtifactFallback used by both call sites. For those
backends the legacy path is not graceful degradation, and the operator
otherwise sees only a timeout with no causal link. The fallback stays
non-fatal.

Drop the os.Chmod(layout.Lock, 0o600) after acquisition: flock.New
already creates the file 0600, and the chmod was gratuitous risk on a
nounix mount that ignores modes.

Fixes #10981


Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-07-20 22:12:39 +02:00

118 lines
3.3 KiB
Go

package gallery
import (
"context"
"fmt"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/pkg/modelartifacts"
)
type ArtifactMaterializer interface {
Ensure(context.Context, string, modelartifacts.Spec) (modelartifacts.Result, error)
}
type installOptions struct {
materializer ArtifactMaterializer
variant string
}
type InstallOption func(*installOptions)
func WithArtifactMaterializer(materializer ArtifactMaterializer) InstallOption {
return func(options *installOptions) {
if materializer != nil {
options.materializer = materializer
}
}
}
// WithVariant pins a gallery entry to a specific variant by name, bypassing
// hardware-based selection. The entry's own name is a valid pin, since the
// entry is itself the last resort. Ignored for entries that declare no
// variants.
func WithVariant(variant string) InstallOption {
return func(options *installOptions) {
options.variant = variant
}
}
func applyInstallOptions(options ...InstallOption) installOptions {
result := installOptions{materializer: modelartifacts.NewDefaultManager()}
for _, option := range options {
option(&result)
}
return result
}
func bindPrimaryArtifact(ctx context.Context, modelsPath string, typed *config.ModelConfig, configMap map[string]any, materializer ArtifactMaterializer, artifactSpec modelartifacts.Spec, inferred bool) (bool, error) {
result, err := materializer.Ensure(ctx, modelsPath, artifactSpec)
if err != nil {
if inferred {
config.LogArtifactFallback(typed.Name, typed.Backend, err)
return false, nil
}
return false, fmt.Errorf("materialize primary model artifact: %w", err)
}
next := []modelartifacts.Spec{result.Spec}
if len(typed.Artifacts) > 1 {
next = append(next, typed.Artifacts[1:]...)
}
typed.Artifacts = next
// Companions are always explicit, so there is no legacy path to degrade to:
// failing here leaves any previously installed config untouched rather than
// persisting a half-acquired model that would fail later inside the backend.
for i := 1; i < len(typed.Artifacts); i++ {
if typed.Artifacts[i].Target != modelartifacts.TargetCompanion {
continue
}
companion, err := materializer.Ensure(ctx, modelsPath, typed.Artifacts[i])
if err != nil {
return false, fmt.Errorf("materialize companion artifact %q: %w", typed.Artifacts[i].Name, err)
}
typed.Artifacts[i] = companion.Spec
}
artifactYAML, err := yaml.Marshal(typed.Artifacts)
if err != nil {
return false, err
}
var artifactValue any
if err := yaml.Unmarshal(artifactYAML, &artifactValue); err != nil {
return false, err
}
configMap["artifacts"] = artifactValue
return true, nil
}
func writeModelConfigAtomic(fileName string, data []byte) error {
temporary, err := os.CreateTemp(filepath.Dir(fileName), ".model-config-*")
if err != nil {
return err
}
temporaryName := temporary.Name()
defer func() { _ = os.Remove(temporaryName) }()
if err := temporary.Chmod(0600); err != nil {
_ = temporary.Close()
return err
}
if _, err := temporary.Write(data); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
_ = temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Chmod(temporaryName, 0644); err != nil {
return err
}
return os.Rename(temporaryName, fileName)
}