mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
Three related backend-lifecycle defects, all reachable from the same production incident on a Jetson/Thor worker: a deleted backend's gRPC process survived ~40 minutes with its directory removed from disk, a later model load was routed to that orphan and failed with a certifi path pointing into the deleted directory, and the admin could not stop the model because the frontend reported it as not loaded. 1. backend.delete orphaned the process it claimed to delete ------------------------------------------------------------ s.processes is keyed by `modelID#replicaIndex` (buildProcessKey), so the backend name never appeared in a key and was recorded nowhere on the process. backend.delete resolved its target via isRunning/stopBackend, whose prefix path only matches a bare *modelID* - a delete keyed on a backend name resolved to zero keys, the stop silently no-op'd, and the files were removed out from under a live process. The install fast path then handed that orphan back out: it returns any live process for the (model, replica) slot without checking which backend started it, so a reinstalled variant inherited the deleted backend's port. - Record backendName on backendProcess, threaded installBackend -> startBackend. - Add resolveProcessKeysForBackend, matching the recorded name and resolving alias <-> concrete via ListSystemBackends *before* DeleteBackendFromSystem erases the metadata that carries the alias. Alias resolution failure degrades to name-only matching so a delete never fails on it. - backend.stop goes through resolveStopTargets, which accepts a backend name, a model name, or an exact modelID#replica key. Its payload field is named "backend" but is published with all three meanings: the admin UI sends a backend name, UnloadRemoteModel sends a model name, and the router's abandoned-load reap (#10948) sends an exact replica key. Narrowing it to backend names alone would strand the latter two. backend.delete stays strict - its identifier is unambiguously a backend. - Gate the install fast path on processMatchesBackend so a slot held by a different backend is restarted rather than reused. Processes with no recorded name (pre-upgrade) are accepted, so rollout does not restart every running backend. - stopBackendExact reports a real stop failure - the process still being alive afterwards, which is precisely what finishBackendStop already detects to keep the entry and its port reserved - and backend.delete no longer replies success when it knew about a process and could not kill it. "No process was running" stays a success but is logged, so the orphan case is visible rather than silent. 2. /backend/shutdown reported a running model as missing --------------------------------------------------------- ModelLoader.deleteProcess short-circuits on a miss in this replica's in-memory store. In distributed mode the authoritative record of "is this model loaded" is the shared node registry: a frontend replica that never served the model itself (load balancer picked a peer, or the replica restarted) has no local entry. The remote unload path that pkg/model documents ("when ShutdownModel is called for a model with no local process, UnloadRemoteModel is called") sat behind that short-circuit, unreachable in exactly the case it exists for. #10865 reworked this function but kept the short-circuit at the top, so the gap survived that refactor. - deleteProcess consults the remote unloader on a local-store miss, via a shared unloadRemote helper so this branch and the existing no-local-process branch both prefer #10865's RemoteModelContextUnloader, preserving force propagation across the distributed boundary. - UnloadRemoteModelContext reports ErrRemoteModelNotLoaded when no node has the model; it previously returned nil, making a no-op stop indistinguishable from a real one. The converse case (nodes have it, none could be stopped) already errors since #10865 joined the per-node failures, so that half of the original fix was dropped as redundant. - Only when the model is absent locally AND cluster-wide does the endpoint report not-found, now 404 naming both scopes rather than a bare 500. - modelNotFoundErr becomes the exported ErrModelNotFound so the HTTP layer can map it without string matching; watchdog's identity comparison becomes errors.Is. 3. Coverage for the bounded Free() that #10865 shipped untested ---------------------------------------------------------------- The original branch also bounded the pre-stop Free(), but #10865 landed that fix first (workerBackendFreeTimeout, applied in both stopBackendExact and handleModelUnload). That production change is therefore DROPPED here as superseded - master's version is strictly better, since it also releases the supervisor mutex across the call and keeps the port reserved until termination completes. What #10865 did not ship is a test, and the bound is load-bearing: the router-side reap in #10948 sends backend.stop for an abandoned load, and against a wedged backend an unbounded Free would swallow that stop before it reached the process. Nothing failed if the bound regressed. The spec stands up a real gRPC backend server whose Free handler never returns - what a Python backend looks like when its single worker thread (PYTHON_GRPC_MAX_WORKERS=1 on 37 backends) is occupied by a stuck LoadModel. A stub socket is not sufficient and was tried first: without a completed HTTP/2 handshake, gRPC's own ~20s connect timeout ends the call, so that version passed against the very bug it targets. With the connection READY, only the caller's deadline can end it, so the spec hangs to its 60s limit if the timeout is removed and passes with it. Its fixture process is deliberately never started. go-processmanager v0.1.1 writes Process.pid from readPID() without synchronization, so a live process races its own monitor goroutine under -race - reproducible with a bare Run()+Stop() and unrelated to this spec. Since scripts/model-lifecycle-conformance.sh runs this package with -race and is fail-closed, starting one would turn that gate red on an upstream defect. An unstarted process still proves the point: the stop is reached and the slot released, which is exactly what an unbounded Free prevents. Verified: make lint (new-from-merge-base origin/master) reports 0 issues; scripts/model-lifecycle-conformance.sh passes all three stages including the FizzBee liveness check (1458 states, IsLive: true). Assisted-by: Claude:claude-opus-4-8 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
392 lines
14 KiB
Go
392 lines
14 KiB
Go
package model
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hpcloud/tail"
|
|
"github.com/mudler/LocalAI/pkg/grpc/grpcerrors"
|
|
"github.com/mudler/LocalAI/pkg/signals"
|
|
process "github.com/mudler/go-processmanager"
|
|
"github.com/mudler/xlog"
|
|
)
|
|
|
|
var forceBackendShutdown bool = os.Getenv("LOCALAI_FORCE_BACKEND_SHUTDOWN") == "true"
|
|
|
|
var (
|
|
// ErrModelNotFound reports that a model is not loaded. Exported so HTTP
|
|
// handlers can map it to 404 instead of a blanket 500.
|
|
ErrModelNotFound = errors.New("model not found")
|
|
// ErrModelBusy indicates that a graceful shutdown context ended while
|
|
// requests were still in flight.
|
|
ErrModelBusy = errors.New("model is still busy")
|
|
|
|
// ErrRemoteModelNotLoaded is returned by a RemoteModelUnloader when no
|
|
// node in the cluster has the model loaded. It exists so the local store
|
|
// miss and the cluster-wide miss stay distinguishable: only when BOTH are
|
|
// empty may we tell the operator the model is not loaded.
|
|
ErrRemoteModelNotLoaded = errors.New("model not loaded on any node")
|
|
)
|
|
|
|
const (
|
|
gracefulShutdownTimeout = 30 * time.Second
|
|
forcedShutdownTimeout = 30 * time.Second
|
|
backendFreeTimeout = 5 * time.Second
|
|
busyPollInterval = 100 * time.Millisecond
|
|
)
|
|
|
|
// unloadRemote asks the remote unloader to stop `s` on whichever node holds
|
|
// it, preferring the context-aware extension so a forced shutdown and the
|
|
// caller's deadline both survive the distributed boundary.
|
|
func unloadRemote(ctx context.Context, u RemoteModelUnloader, s string, force bool) error {
|
|
if contextUnloader, ok := u.(RemoteModelContextUnloader); ok {
|
|
return contextUnloader.UnloadRemoteModelContext(ctx, s, force)
|
|
}
|
|
return u.UnloadRemoteModel(s)
|
|
}
|
|
|
|
// deleteProcess stops and removes a backend. The force flag trades a graceful
|
|
// shutdown for a prompt one and is meant for the watchdog's busy-killer: a
|
|
// backend that has been busy past the watchdog timeout may be stuck in an
|
|
// in-flight gRPC call. The graceful path waits only until ctx expires; the
|
|
// force path skips that wait and Free(), stops the process, then cleans up.
|
|
// Callers serialize this operation per model, never with the global loader
|
|
// mutex, so a faulty backend cannot stall unrelated model lifecycle work.
|
|
func (ml *ModelLoader) deleteProcess(ctx context.Context, s string, force bool) error {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
// Snapshot mutable loader configuration while holding ml.mu, then perform
|
|
// every wait, callback, RPC, and process operation without the global lock.
|
|
// Same-model ordering is provided by modelOperationLocks at the public
|
|
// lifecycle boundary.
|
|
ml.mu.Lock()
|
|
store := ml.store
|
|
wd := ml.wd
|
|
hooks := append([]ModelUnloadHook(nil), ml.onUnloadHooks...)
|
|
remoteUnloader := ml.remoteUnloader
|
|
ml.mu.Unlock()
|
|
|
|
model, ok := store.Get(s)
|
|
if !ok {
|
|
// A local-store miss is not proof the model is unloaded. In
|
|
// distributed mode the model runs on a worker and the authoritative
|
|
// record is the shared node registry: any frontend replica that did
|
|
// not itself serve the model (load balancer picked a peer, or this
|
|
// replica restarted) has no local entry. Returning "model not found"
|
|
// here reported a model that was demonstrably running as absent, and
|
|
// left its backend process untouched.
|
|
if remoteUnloader != nil {
|
|
xlog.Debug("Model not in local store; asking the remote unloader", "model", s)
|
|
if err := unloadRemote(ctx, remoteUnloader, s, force); err != nil {
|
|
if errors.Is(err, ErrRemoteModelNotLoaded) {
|
|
// Absent locally AND cluster-wide: genuinely not loaded.
|
|
return ErrModelNotFound
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
xlog.Debug("Model not found", "model", s)
|
|
return ErrModelNotFound
|
|
}
|
|
|
|
if !force {
|
|
client := model.GRPC(false, wd)
|
|
for client.IsBusy() {
|
|
xlog.Debug("Model busy. Waiting.", "model", s)
|
|
select {
|
|
case <-ctx.Done():
|
|
return fmt.Errorf("%w: %s: %w", ErrModelBusy, s, ctx.Err())
|
|
case <-time.After(busyPollInterval):
|
|
}
|
|
}
|
|
}
|
|
|
|
xlog.Debug("Deleting process", "model", s, "force", force)
|
|
|
|
// Run unload hooks (e.g. close MCP sessions)
|
|
for _, hook := range hooks {
|
|
hook(s)
|
|
}
|
|
|
|
// Free GPU resources before stopping the process to ensure VRAM is
|
|
// released. Skipped on force-shutdown: a stuck-busy backend won't answer
|
|
// a Free RPC (it's hung on the same stuck call), and stopping the
|
|
// process releases its VRAM anyway. Free is optional: backends that
|
|
// don't override it (the generated stub, many Python/external backends,
|
|
// or a federation proxy in distributed mode) return gRPC Unimplemented.
|
|
// That is expected, not a failure — VRAM is reclaimed when the process
|
|
// is stopped below, or by the remote unloader for remote backends — so
|
|
// don't surface it as an error.
|
|
if !force {
|
|
xlog.Debug("Calling Free() to release GPU resources", "model", s)
|
|
freeCtx, cancel := context.WithTimeout(ctx, backendFreeTimeout)
|
|
err := model.GRPC(false, wd).Free(freeCtx)
|
|
cancel()
|
|
if err != nil {
|
|
if grpcerrors.IsUnimplemented(err) {
|
|
xlog.Debug("Backend does not implement Free(); GPU release handled on process stop", "model", s)
|
|
} else {
|
|
// Now that the expected Unimplemented case is filtered out above, a
|
|
// remaining error is a genuine failure to release VRAM — surface it.
|
|
xlog.Error("Error freeing GPU resources", "error", err, "model", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
process := model.Process()
|
|
if process == nil {
|
|
// No local process — this is a remote/external backend.
|
|
// In distributed mode, delegate to the remote unloader to tell
|
|
// the backend node to free the model (GPU resources, etc.).
|
|
var unloadErr error
|
|
if remoteUnloader != nil {
|
|
xlog.Debug("Delegating model unload to remote unloader", "model", s)
|
|
unloadErr = unloadRemote(ctx, remoteUnloader, s, force)
|
|
if unloadErr != nil {
|
|
xlog.Warn("Remote model unload failed", "model", s, "error", unloadErr)
|
|
}
|
|
} else {
|
|
xlog.Debug("No local process and no remote unloader", "model", s)
|
|
}
|
|
// The store is only the frontend's local representative of a remote
|
|
// model. Never retain it after an unload attempt: on failure it may point
|
|
// at a known-unreachable worker, while the distributed registry remains
|
|
// the source of truth for anything that is still running remotely.
|
|
store.Delete(s)
|
|
return unloadErr
|
|
}
|
|
|
|
// Mark the stop as intentional so the exit-watcher logs it as an
|
|
// expected stop, not a crash (signal-terminated children report -1).
|
|
ml.stoppingProcs.Store(process, struct{}{})
|
|
err := process.Stop()
|
|
if err != nil {
|
|
xlog.Error("(deleteProcess) error while deleting process", "error", err, "model", s)
|
|
if !process.IsAlive() {
|
|
// A concurrently crashed/already-reaped process can no longer own
|
|
// resources even if Stop could not read or signal its PID.
|
|
store.Delete(s)
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
store.Delete(s)
|
|
return nil
|
|
}
|
|
func (ml *ModelLoader) StopGRPC(filter GRPCProcessFilter) error {
|
|
var err error = nil
|
|
ml.mu.Lock()
|
|
store := ml.store
|
|
ml.mu.Unlock()
|
|
|
|
// Collect matching keys first — can't mutate store during Range
|
|
var toDelete []string
|
|
store.Range(func(k string, m *Model) bool {
|
|
if filter(k, m.Process()) {
|
|
toDelete = append(toDelete, k)
|
|
}
|
|
return true
|
|
})
|
|
for _, k := range toDelete {
|
|
e := ml.ShutdownModel(k)
|
|
err = errors.Join(err, e)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (ml *ModelLoader) StopAllGRPC() error {
|
|
return ml.StopGRPC(all)
|
|
}
|
|
|
|
func (ml *ModelLoader) GetGRPCPID(id string) (int, error) {
|
|
ml.mu.Lock()
|
|
store := ml.store
|
|
ml.mu.Unlock()
|
|
p, exists := store.Get(id)
|
|
if !exists {
|
|
return -1, fmt.Errorf("no grpc backend found for %s", id)
|
|
}
|
|
if p.Process() == nil {
|
|
return -1, fmt.Errorf("no grpc backend found for %s", id)
|
|
}
|
|
return strconv.Atoi(p.Process().PID)
|
|
}
|
|
|
|
// StartProcess starts a gRPC backend process and returns its process handle.
|
|
// This is the public wrapper for the internal startProcess method, used by
|
|
// the serve-backend CLI subcommand to start a backend on a specified address.
|
|
func (ml *ModelLoader) StartProcess(grpcProcess, id string, serverAddress string, args ...string) (*process.Process, error) {
|
|
return ml.startProcess(grpcProcess, id, serverAddress, args...)
|
|
}
|
|
|
|
func (ml *ModelLoader) startProcess(grpcProcess, id string, serverAddress string, args ...string) (*process.Process, error) {
|
|
// Make sure the process is executable
|
|
// Check first if it has executable permissions
|
|
if fi, err := os.Stat(grpcProcess); err == nil {
|
|
if fi.Mode()&0111 == 0 {
|
|
xlog.Debug("Process is not executable. Making it executable.", "process", grpcProcess)
|
|
if err := os.Chmod(grpcProcess, 0700); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
xlog.Debug("Loading GRPC Process", "process", grpcProcess)
|
|
|
|
xlog.Debug("GRPC Service will be running", "id", id, "address", serverAddress)
|
|
|
|
workDir, err := filepath.Abs(filepath.Dir(grpcProcess))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
env := os.Environ()
|
|
// Vulkan backends are self-contained: they bundle their own loader and
|
|
// Mesa driver .so files in lib/ plus the matching ICD manifests in
|
|
// vulkan/icd.d/. Point the loader at those manifests so it doesn't rely on
|
|
// the runtime base image shipping a Vulkan driver (it carries the
|
|
// SYCL/Level-Zero stack instead, so the default ICD search path is empty
|
|
// and the GPU would silently fall back to CPU). No-op for other backends.
|
|
env = append(env, vulkanICDEnv(workDir)...)
|
|
|
|
grpcControlProcess := process.New(
|
|
process.WithTemporaryStateDir(),
|
|
process.WithName(filepath.Base(grpcProcess)),
|
|
process.WithArgs(append(args, []string{"--addr", serverAddress}...)...),
|
|
process.WithEnvironment(env...),
|
|
process.WithWorkDir(workDir),
|
|
)
|
|
|
|
if ml.wd != nil {
|
|
ml.wd.Add(serverAddress, grpcControlProcess)
|
|
ml.wd.AddAddressModelMap(serverAddress, id)
|
|
}
|
|
|
|
if err := grpcControlProcess.Run(); err != nil {
|
|
return grpcControlProcess, err
|
|
}
|
|
|
|
xlog.Debug("GRPC Service state dir", "dir", grpcControlProcess.StateDir())
|
|
|
|
signals.RegisterGracefulTerminationHandler(func() {
|
|
// StopAllGRPC (the deleteProcess path) is registered earlier and runs
|
|
// first for store-tracked backends, stopping this process and removing
|
|
// its pidfile. Calling Stop again then fails with "failed to read PID".
|
|
// Skip when it's already gone; this handler still covers processes that
|
|
// StopAllGRPC doesn't track (e.g. worker-supervised backends).
|
|
if !grpcControlProcess.IsAlive() {
|
|
return
|
|
}
|
|
ml.stoppingProcs.Store(grpcControlProcess, struct{}{})
|
|
if err := grpcControlProcess.Stop(); err != nil {
|
|
xlog.Error("error while shutting down grpc process", "error", err)
|
|
}
|
|
})
|
|
|
|
go func() {
|
|
t, err := tail.TailFile(grpcControlProcess.StderrPath(), tail.Config{Follow: true})
|
|
if err != nil {
|
|
xlog.Error("Could not tail stderr", "process", grpcProcess)
|
|
return
|
|
}
|
|
for line := range t.Lines {
|
|
xlog.Debug("GRPC stderr", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
|
|
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
|
|
ml.backendLogs.AppendLine(id, "stderr", line.Text)
|
|
}
|
|
}
|
|
}()
|
|
go func() {
|
|
t, err := tail.TailFile(grpcControlProcess.StdoutPath(), tail.Config{Follow: true})
|
|
if err != nil {
|
|
xlog.Error("Could not tail stdout", "process", grpcProcess)
|
|
return
|
|
}
|
|
for line := range t.Lines {
|
|
xlog.Debug("GRPC stdout", "id", strings.Join([]string{id, serverAddress}, "-"), "line", line.Text)
|
|
if ml.backendLogs != nil && ml.backendLoggingEnabled.Load() {
|
|
ml.backendLogs.AppendLine(id, "stdout", line.Text)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Surface backend exits in the log. Without this, a crash (SIGSEGV
|
|
// from a missing shared library, a Python ImportError, etc.) is
|
|
// invisible at every log level — the only signal is a delayed
|
|
// "connection refused" from the gRPC dial, which doesn't say
|
|
// whether the child is alive.
|
|
go func() {
|
|
<-grpcControlProcess.Done()
|
|
// LoadAndDelete both reads the intentional-stop marker and frees the
|
|
// map entry so it doesn't accumulate across the process's lifetime.
|
|
_, intentional := ml.stoppingProcs.LoadAndDelete(grpcControlProcess)
|
|
fields := []any{
|
|
"id", id,
|
|
"address", serverAddress,
|
|
"process", filepath.Base(grpcProcess),
|
|
}
|
|
// Report the raw exit code without interpreting it: a child killed by
|
|
// our own SIGTERM/SIGKILL surfaces as -1 (Go reports -1 for signal
|
|
// termination, not the shell's 128+signal convention), so the code
|
|
// alone can't tell an intended stop from a crash. The stoppingProcs
|
|
// marker is the reliable signal for that, so it picks the log level.
|
|
if code, codeErr := grpcControlProcess.ExitCode(); codeErr == nil {
|
|
fields = append(fields, "exitCode", code)
|
|
}
|
|
if intentional {
|
|
xlog.Info("Backend process stopped", fields...)
|
|
} else {
|
|
// A stop we didn't initiate — a SIGSEGV from a missing shared
|
|
// library, a Python ImportError, an OOM kill, an unexpected self-exit.
|
|
xlog.Warn("Backend process exited unexpectedly", fields...)
|
|
}
|
|
}()
|
|
|
|
return grpcControlProcess, nil
|
|
}
|
|
|
|
// vulkanICDEnv returns environment overrides that point the Vulkan loader at
|
|
// the ICD manifests a backend bundles in <workDir>/vulkan/icd.d. Vulkan
|
|
// backends ship a self-contained stack — their own loader and Mesa driver .so
|
|
// files in lib/ (resolved via the LD_LIBRARY_PATH that run.sh sets) plus the
|
|
// matching ICD manifests — so the loader must be told where those manifests
|
|
// live; its default search path (/usr/share/vulkan/icd.d, /etc/vulkan/icd.d)
|
|
// is empty on the runtime base image. Returns nil when the directory holds no
|
|
// manifests (CPU/CUDA/SYCL builds), leaving the host's Vulkan setup untouched.
|
|
func vulkanICDEnv(workDir string) []string {
|
|
icdDir := filepath.Join(workDir, "vulkan", "icd.d")
|
|
entries, err := os.ReadDir(icdDir)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
|
|
manifests := make([]string, 0, len(entries))
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
manifests = append(manifests, filepath.Join(icdDir, e.Name()))
|
|
}
|
|
if len(manifests) == 0 {
|
|
return nil
|
|
}
|
|
|
|
list := strings.Join(manifests, string(os.PathListSeparator))
|
|
// VK_DRIVER_FILES is the current loader variable; VK_ICD_FILENAMES is its
|
|
// deprecated alias, set too so older bundled loaders still pick it up.
|
|
return []string{
|
|
"VK_DRIVER_FILES=" + list,
|
|
"VK_ICD_FILENAMES=" + list,
|
|
}
|
|
}
|