Files
LocalAI/core/services/worker/registration.go
mudler's LocalAI [bot] e55cc3e2a7 fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports (#10968)
The worker's gRPC port allocator grew monotonically with no upper bound:
nextPort started at the base port and incremented whenever freePorts was
empty, and nothing checked 65535. Past that it handed out integers that
cannot be bound, surfacing as an opaque "backend won't start".

#10961 estimated this needed ~15,000 concurrent-peak allocations, i.e.
effectively unreachable. It is not, because of a second defect: the
"process died unexpectedly" branch in startBackend deleted the process
map entry without releasing its port at all. That port was leaked, never
quarantined and never reused. A crash-looping backend leaks one port per
restart, so a backend dying every 30s walks 50051 to 65535 in about five
days. The leak, not concurrent peak, is the realistic route to exhaustion.

Fixing the leak alone would have been wrong. Releasing that port makes it
re-bindable, and the death path is the one teardown path with no
request/reply to carry StoppedProcessKeys back to the controller (#10952's
eager row removal), so a stale NodeModel row could then resolve to a live
listener belonging to a different backend. probeHealth verifies liveness,
not identity, so the request is silently misrouted. The 15s port
quarantine does not cover this: the only reaper is the per-model health
check at ~45s, and it can be disabled outright. The residual was masked
only because the port was never rebound.

So both are fixed together:

- The allocator takes an explicit [basePort, LOCALAI_GRPC_MAX_PORT] range
  and returns ErrNoFreePort naming the range, the live backend count, the
  quarantined count, and the knob to raise. Exhaustion is now diagnosable
  instead of surfacing as an unbindable port.

- Released ports carry per-key affinity: a port is offered back to the
  process key that last held it before any other key. Process keys
  (modelID#replica) and NodeModel rows (nodeID, modelName, replicaIndex)
  are isomorphic, so a port that can only be re-bound by its previous
  owner can only ever be named by that owner's row, which that key's
  re-registration overwrites. Misrouting to a different model becomes
  impossible by construction rather than by racing the quarantine timer.

Affinity is a preference, not a reservation: under range pressure an owned
port is stolen with a warning, because a guaranteed outage is worse than a
rare misroute window on a port long out of quarantine. Claiming a port
evicts its previous owner's entry, keeping ownership injective over ports
so the affinity map can never exceed the range width regardless of how
many distinct model keys the worker sees.

Ownership also expires. It is only load-bearing while a controller row
could still name the port, which the per-model reaper bounds at roughly
45s, so it lapses after five minutes and the port becomes ordinary free
space again. Holding it indefinitely would have made every distinct model
the worker ever served consume a port permanently: every release path is
keyed, so nothing would ever be unowned, the allocator would climb to the
end of its range on distinct-key count rather than concurrency, stealing
would become routine, and the steal warning would tell operators to widen
a range that was not the constraint. With expiry, reaching the steal
branch means the worker is genuinely out of concurrent capacity, so that
advice is correct when it appears.

Closes #10961
Closes #10952


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-19 23:56:37 +00:00

226 lines
7.7 KiB
Go

package worker
import (
"cmp"
"fmt"
"net"
"os"
"strconv"
"strings"
"github.com/mudler/LocalAI/pkg/system"
"github.com/mudler/LocalAI/pkg/xsysinfo"
"github.com/mudler/xlog"
)
// effectiveBasePort returns the port used as base for gRPC backend processes.
// Priority: Addr port → ServeAddr port → 50051
func (cfg *Config) effectiveBasePort() int {
for _, addr := range []string{cfg.Addr, cfg.ServeAddr} {
if addr == "" {
continue
}
_, portStr, err := net.SplitHostPort(addr)
if err != nil {
xlog.Warn("Invalid worker address; trying the next base-port source", "addr", addr, "error", err)
continue
}
port, err := strconv.Atoi(portStr)
if err != nil {
xlog.Warn("Invalid worker port; trying the next base-port source", "addr", addr, "port", portStr, "error", err)
continue
}
if port > 0 && port <= 65535 {
return port
}
xlog.Warn("Worker port is outside the valid range; trying the next base-port source", "addr", addr, "port", port)
}
return 50051
}
// effectiveMaxPort returns the last port the gRPC backend allocator may hand
// out. The range is [basePort, maxPort]; its width is the number of backend
// processes this worker can run concurrently, minus whatever the port
// quarantine is holding at the time.
//
// An unset, non-positive, or out-of-order value falls back to 65535 — the
// historical (unbounded) behaviour clamped to something bindable.
// Misconfiguring this must not shrink the range to nothing and wedge every
// backend start on the worker.
func (cfg *Config) effectiveMaxPort(basePort int) int {
if cfg.GRPCMaxPort <= 0 {
return defaultMaxPort
}
if cfg.GRPCMaxPort > defaultMaxPort {
xlog.Warn("Configured gRPC max port is above the highest TCP port; clamping",
"configured", cfg.GRPCMaxPort, "max", defaultMaxPort)
return defaultMaxPort
}
if cfg.GRPCMaxPort < basePort {
xlog.Warn("Configured gRPC max port is below the base port; ignoring it and using the full range",
"configured", cfg.GRPCMaxPort, "basePort", basePort, "max", defaultMaxPort)
return defaultMaxPort
}
return cfg.GRPCMaxPort
}
// advertiseAddr returns the address the frontend should use to reach this node.
func (cfg *Config) advertiseAddr() string {
if cfg.AdvertiseAddr != "" {
return cfg.AdvertiseAddr
}
if cfg.Addr != "" {
return cfg.Addr
}
hostname, err := os.Hostname()
if err != nil {
xlog.Warn("Failed to determine worker hostname; advertising localhost", "error", err)
}
return fmt.Sprintf("%s:%d", cmp.Or(hostname, "localhost"), cfg.effectiveBasePort())
}
// resolveHTTPAddr returns the address to bind the HTTP file transfer server to.
// Uses basePort-1 so it doesn't conflict with dynamically allocated gRPC ports
// which grow upward from basePort.
func (cfg *Config) resolveHTTPAddr() string {
if cfg.HTTPAddr != "" {
return cfg.HTTPAddr
}
return fmt.Sprintf("0.0.0.0:%d", cfg.effectiveBasePort()-1)
}
// advertiseHTTPAddr returns the HTTP address the frontend should use to reach
// this node for file transfer.
func (cfg *Config) advertiseHTTPAddr() string {
if cfg.AdvertiseHTTPAddr != "" {
return cfg.AdvertiseHTTPAddr
}
advertiseAddr := cfg.advertiseAddr()
advHost, _, err := net.SplitHostPort(advertiseAddr)
if err != nil {
xlog.Warn("Invalid worker advertise address; advertising file transfer on localhost", "addr", advertiseAddr, "error", err)
advHost = "localhost"
}
httpPort := cfg.effectiveBasePort() - 1
return net.JoinHostPort(advHost, strconv.Itoa(httpPort))
}
// registrationBody builds the JSON body for node registration.
func (cfg *Config) registrationBody() map[string]any {
nodeName := cfg.NodeName
if nodeName == "" {
hostname, err := os.Hostname()
if err != nil {
nodeName = fmt.Sprintf("node-%d", os.Getpid())
} else {
nodeName = hostname
}
}
// Detect GPU info for VRAM-aware scheduling
totalVRAM, err := xsysinfo.TotalAvailableVRAM()
if err != nil {
xlog.Debug("Failed to detect worker VRAM; registering without GPU capacity", "error", err)
}
gpuVendor, err := xsysinfo.DetectGPUVendor()
if err != nil {
xlog.Debug("Failed to detect worker GPU vendor; registering without vendor metadata", "error", err)
}
// Compute capability (e.g. "12.1" for GB10) lets the router pick per-arch
// options (e.g. larger physical batch on Blackwell). Detected on the worker
// because only the worker sees the GPU in distributed mode.
gpuComputeCap := xsysinfo.NVIDIAComputeCapability()
// Report our own meta-backend capability so the controller can list the
// backends this cluster can actually run. The controller cannot infer it
// from the GPU vendor alone: OS-dependent capabilities (metal, darwin-x86,
// nvidia-l4t) and the CUDA runtime refinements are only observable here.
capability := ""
if systemState, err := system.GetSystemState(); err != nil {
xlog.Warn("Could not detect system capability for node registration", "error", err)
} else {
capability = systemState.DetectedCapability()
}
maxReplicas := cfg.MaxReplicasPerModel
if maxReplicas < 1 {
maxReplicas = 1
}
body := map[string]any{
"name": nodeName,
"address": cfg.advertiseAddr(),
"http_address": cfg.advertiseHTTPAddr(),
"total_vram": totalVRAM,
"available_vram": totalVRAM, // initially all VRAM is available
"gpu_vendor": gpuVendor,
"gpu_compute_capability": gpuComputeCap,
"capability": capability,
"max_replicas_per_model": maxReplicas,
}
// Report the operator-set budget as a STRING so the server resolves and
// enforces it against the raw VRAM above. The worker never caps its own
// total_vram/available_vram, and never touches the xsysinfo process-global
// budget (that is standalone-only). Omit when unset.
if cfg.VRAMBudget != "" {
body["vram_budget"] = cfg.VRAMBudget
}
// If no GPU detected, report system RAM so the scheduler/UI has capacity info
if totalVRAM == 0 {
ramInfo, err := xsysinfo.GetSystemRAMInfo()
if err != nil {
xlog.Debug("Failed to detect worker RAM for registration", "error", err)
} else {
body["total_ram"] = ramInfo.Total
body["available_ram"] = ramInfo.Available
}
}
if cfg.RegistrationToken != "" {
body["token"] = cfg.RegistrationToken
}
// Parse and add static node labels. Always include the auto-label
// `node.replica-slots=N` so AND-selectors in ModelSchedulingConfig can
// target high-capacity nodes (e.g. {"node.replica-slots":"4"}).
labels := make(map[string]string)
if cfg.NodeLabels != "" {
for _, pair := range strings.Split(cfg.NodeLabels, ",") {
pair = strings.TrimSpace(pair)
if k, v, ok := strings.Cut(pair, "="); ok {
labels[strings.TrimSpace(k)] = strings.TrimSpace(v)
}
}
}
labels["node.replica-slots"] = strconv.Itoa(maxReplicas)
body["labels"] = labels
return body
}
// heartbeatBody returns the current VRAM/RAM stats for heartbeat payloads.
//
// When aggregate VRAM usage is unknown (no GPU, or temporary detection
// failure), we deliberately OMIT available_vram so the frontend keeps its
// last good value — overwriting with 0 makes the UI show the node as "fully
// used", while reporting total-as-available lies to the scheduler about
// free capacity.
func (cfg *Config) heartbeatBody() map[string]any {
body := map[string]any{}
aggregate := xsysinfo.GetGPUAggregateInfo()
if aggregate.TotalVRAM > 0 {
body["available_vram"] = aggregate.FreeVRAM
}
// CPU-only workers (or workers that lost GPU visibility momentarily):
// report system RAM so the scheduler still has capacity info.
if aggregate.TotalVRAM == 0 {
ramInfo, err := xsysinfo.GetSystemRAMInfo()
if err != nil {
xlog.Debug("Failed to detect worker RAM for heartbeat", "error", err)
} else {
body["available_ram"] = ramInfo.Available
}
}
return body
}