Skip to content

Commit e6f7501

Browse files
committed
fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports
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 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude Code:claude-opus-4-8[1m] [Read] [Edit] [Bash]
1 parent 9d82c37 commit e6f7501

7 files changed

Lines changed: 620 additions & 33 deletions

File tree

core/services/worker/config.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,13 @@ type Config struct {
2222
Addr string `env:"LOCALAI_ADDR" help:"Address where this worker is reachable (host:port). Port is base for gRPC backends, port-1 for HTTP." group:"server"`
2323
ServeAddr string `env:"LOCALAI_SERVE_ADDR" default:"0.0.0.0:50051" help:"(Advanced) gRPC base port bind address" group:"server" hidden:""`
2424

25+
// GRPCMaxPort bounds the dynamic gRPC port allocator at [basePort, this].
26+
// The width of that range is how many backend processes this worker can run
27+
// concurrently; released ports also sit in a short quarantine before reuse,
28+
// so a worker with heavy start/stop churn needs headroom above its true
29+
// concurrency. 0 = up to 65535.
30+
GRPCMaxPort int `env:"LOCALAI_GRPC_MAX_PORT" default:"0" help:"Highest port the worker may assign to a backend gRPC process. The range is [base port, this]; its width caps concurrent backends on this worker. 0 uses up to 65535." group:"server"`
31+
2532
BackendsPath string `env:"LOCALAI_BACKENDS_PATH,BACKENDS_PATH" type:"path" default:"${basepath}/backends" help:"Path containing backends" group:"server"`
2633
BackendsSystemPath string `env:"LOCALAI_BACKENDS_SYSTEM_PATH" type:"path" default:"/var/lib/local-ai/backends" help:"Path containing system backends" group:"server"`
2734
BackendGalleries string `env:"LOCALAI_BACKEND_GALLERIES,BACKEND_GALLERIES" help:"JSON list of backend galleries" group:"server" default:"${backends}"`
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package worker
2+
3+
import (
4+
"time"
5+
6+
. "github.com/onsi/ginkgo/v2"
7+
. "github.com/onsi/gomega"
8+
)
9+
10+
// mustAllocate fails the spec if allocation errors, so the ordinary "give me a
11+
// port" specs below stay readable while still refusing to assert on a zero
12+
// value that the allocator never intended to hand out.
13+
func mustAllocate(s *backendSupervisor, key string) int {
14+
port, err := s.allocatePort(key)
15+
ExpectWithOffset(1, err).NotTo(HaveOccurred())
16+
return port
17+
}
18+
19+
var _ = Describe("Backend gRPC port allocator", func() {
20+
Describe("range exhaustion", func() {
21+
It("reports an explicit error instead of handing out an unbindable port", func() {
22+
// The allocator used to increment without an upper bound. Past
23+
// 65535 it kept returning larger integers, which cannot be bound,
24+
// so the operator saw "backend won't start" with nothing pointing
25+
// at the allocator. Exhaustion must be named as exhaustion.
26+
s := &backendSupervisor{
27+
processes: map[string]*backendProcess{},
28+
minPort: 50051,
29+
nextPort: 50051,
30+
maxPort: 50052,
31+
portQuarantine: time.Hour,
32+
}
33+
34+
first := mustAllocate(s, "a#0")
35+
second := mustAllocate(s, "b#0")
36+
Expect([]int{first, second}).To(ConsistOf(50051, 50052))
37+
38+
_, err := s.allocatePort("c#0")
39+
Expect(err).To(MatchError(ErrNoFreePort))
40+
// The operator has to learn which knob to turn from the message
41+
// alone; a bare "no free port" sends them reading source.
42+
Expect(err.Error()).To(ContainSubstring("50051"))
43+
Expect(err.Error()).To(ContainSubstring("50052"))
44+
Expect(err.Error()).To(ContainSubstring("LOCALAI_GRPC_MAX_PORT"))
45+
})
46+
47+
It("does not hand out a port beyond the end of the range", func() {
48+
s := &backendSupervisor{
49+
processes: map[string]*backendProcess{},
50+
minPort: 50051,
51+
nextPort: 50051,
52+
maxPort: 50053,
53+
portQuarantine: time.Hour,
54+
}
55+
56+
for i := 0; i < 3; i++ {
57+
port := mustAllocate(s, "model#0")
58+
Expect(port).To(BeNumerically(">=", 50051))
59+
Expect(port).To(BeNumerically("<=", 50053))
60+
}
61+
})
62+
})
63+
64+
Describe("unexpectedly dead processes", func() {
65+
It("returns the dead process's port to the allocator", func() {
66+
// The death path deleted the map entry without releasing the port,
67+
// so every unexpected exit permanently consumed one port. A
68+
// crash-looping backend leaks one per restart, which is the real
69+
// route to exhausting the range — not the concurrent-peak growth
70+
// the issue assumed.
71+
bp := &backendProcess{port: 50051}
72+
s := &backendSupervisor{
73+
processes: map[string]*backendProcess{"model#0": bp},
74+
minPort: 50051,
75+
nextPort: 50052,
76+
maxPort: 50060,
77+
portQuarantine: time.Millisecond,
78+
}
79+
80+
s.reapDeadProcess("model#0", bp)
81+
82+
Expect(s.processes).NotTo(HaveKey("model#0"))
83+
// Quarantined, not lost: the port must come back.
84+
Expect(quarantinedPortNumbers(s)).To(ContainElement(50051))
85+
time.Sleep(5 * time.Millisecond)
86+
Expect(mustAllocate(s, "model#0")).To(Equal(50051))
87+
})
88+
89+
It("does not exhaust the range when one backend crash-loops", func() {
90+
// Restarting the same key repeatedly must not walk the allocator up
91+
// the range. Before the fix this consumed a fresh port per restart.
92+
s := &backendSupervisor{
93+
processes: map[string]*backendProcess{},
94+
minPort: 50051,
95+
nextPort: 50051,
96+
maxPort: 50053,
97+
portQuarantine: time.Millisecond,
98+
}
99+
100+
for i := 0; i < 10; i++ {
101+
port, err := s.allocatePort("crashy#0")
102+
Expect(err).NotTo(HaveOccurred(), "restart %d exhausted the range", i)
103+
bp := &backendProcess{port: port}
104+
s.processes["crashy#0"] = bp
105+
s.reapDeadProcess("crashy#0", bp)
106+
time.Sleep(2 * time.Millisecond)
107+
}
108+
})
109+
})
110+
111+
Describe("effectiveMaxPort", func() {
112+
It("uses the full range when unset", func() {
113+
Expect((&Config{}).effectiveMaxPort(50051)).To(Equal(65535))
114+
})
115+
116+
It("honours an operator-set ceiling", func() {
117+
Expect((&Config{GRPCMaxPort: 51000}).effectiveMaxPort(50051)).To(Equal(51000))
118+
})
119+
120+
It("clamps a ceiling above the highest TCP port", func() {
121+
Expect((&Config{GRPCMaxPort: 70000}).effectiveMaxPort(50051)).To(Equal(65535))
122+
})
123+
124+
It("ignores a ceiling below the base port instead of wedging every start", func() {
125+
// An inverted range would leave the allocator with nothing to hand
126+
// out, so a typo here would take the whole worker down rather than
127+
// degrade it.
128+
Expect((&Config{GRPCMaxPort: 40000}).effectiveMaxPort(50051)).To(Equal(65535))
129+
})
130+
})
131+
132+
Describe("per-key port affinity", func() {
133+
It("never hands one key's released port to a different key", func() {
134+
// This is what makes a stale NodeModel row harmless. Process keys
135+
// (modelID#replica) and NodeModel rows (nodeID, modelName,
136+
// replicaIndex) are isomorphic, so if a port can only ever be
137+
// re-bound by the key that last held it, the only row that can name
138+
// that port is that key's own row — which its re-registration
139+
// overwrites. Misrouting to a *different* model becomes impossible
140+
// by construction rather than by racing a quarantine timer.
141+
s := &backendSupervisor{
142+
processes: map[string]*backendProcess{},
143+
minPort: 50051,
144+
nextPort: 50051,
145+
maxPort: 50060,
146+
portQuarantine: time.Millisecond,
147+
}
148+
149+
portA := mustAllocate(s, "alpha#0")
150+
s.releasePortForKey("alpha#0", portA)
151+
time.Sleep(5 * time.Millisecond)
152+
153+
// beta must NOT be given alpha's port while unused ports remain.
154+
portB := mustAllocate(s, "beta#0")
155+
Expect(portB).NotTo(Equal(portA))
156+
157+
// alpha coming back gets its own port again.
158+
Expect(mustAllocate(s, "alpha#0")).To(Equal(portA))
159+
})
160+
161+
It("reuses an unowned port before growing the range", func() {
162+
s := &backendSupervisor{
163+
processes: map[string]*backendProcess{},
164+
minPort: 50051,
165+
nextPort: 50051,
166+
maxPort: 50060,
167+
portQuarantine: time.Millisecond,
168+
}
169+
170+
// A port released without an owning key (legacy/unknown) is free
171+
// for anyone: no row can be attributed to it.
172+
s.releasePort(50051)
173+
time.Sleep(5 * time.Millisecond)
174+
175+
Expect(mustAllocate(s, "gamma#0")).To(Equal(50051))
176+
})
177+
178+
It("steals another key's port rather than failing an exhausted range", func() {
179+
// Affinity is a preference, not a reservation. Refusing to start a
180+
// backend because a long-gone key still owns the last port trades a
181+
// vanishingly rare misroute for a guaranteed outage.
182+
s := &backendSupervisor{
183+
processes: map[string]*backendProcess{},
184+
minPort: 50051,
185+
nextPort: 50051,
186+
maxPort: 50051,
187+
portQuarantine: time.Millisecond,
188+
}
189+
190+
portA := mustAllocate(s, "alpha#0")
191+
s.releasePortForKey("alpha#0", portA)
192+
time.Sleep(5 * time.Millisecond)
193+
194+
port, err := s.allocatePort("beta#0")
195+
Expect(err).NotTo(HaveOccurred())
196+
Expect(port).To(Equal(portA))
197+
})
198+
199+
It("stops sequestering the port once the stale-row window has passed", func() {
200+
// Affinity exists to outlive any controller row that could still
201+
// name the port. Past that, holding it for a key that may never
202+
// come back is pure cost: every distinct model the worker has ever
203+
// served would consume a port permanently, so a long-lived worker
204+
// would climb to the end of its range on distinct-key count rather
205+
// than on concurrency, then steal on every subsequent allocation
206+
// while advising the operator to raise a ceiling that is not the
207+
// problem.
208+
s := &backendSupervisor{
209+
processes: map[string]*backendProcess{},
210+
minPort: 50051,
211+
nextPort: 50051,
212+
maxPort: 50060,
213+
portQuarantine: time.Millisecond,
214+
portAffinityWindow: 20 * time.Millisecond,
215+
}
216+
217+
portA := mustAllocate(s, "alpha#0")
218+
s.releasePortForKey("alpha#0", portA)
219+
time.Sleep(60 * time.Millisecond)
220+
221+
// A brand-new key reuses it rather than growing the range.
222+
Expect(mustAllocate(s, "beta#0")).To(Equal(portA))
223+
})
224+
225+
It("applies the default affinity window when none is configured", func() {
226+
// The zero value must not degrade to "no affinity": every
227+
// supervisor built outside the tests leaves the field unset, and
228+
// zero would hand a just-released port straight to another model.
229+
s := &backendSupervisor{
230+
processes: map[string]*backendProcess{},
231+
minPort: 50051,
232+
nextPort: 50051,
233+
maxPort: 50060,
234+
portQuarantine: time.Millisecond,
235+
}
236+
237+
portA := mustAllocate(s, "alpha#0")
238+
s.releasePortForKey("alpha#0", portA)
239+
time.Sleep(5 * time.Millisecond)
240+
241+
Expect(mustAllocate(s, "beta#0")).NotTo(Equal(portA))
242+
})
243+
244+
It("keeps the affinity map bounded by the size of the port range", func() {
245+
// The affinity map must not become the port leak's twin. Handing a
246+
// port to a new key drops the previous owner's entry, so the map is
247+
// injective over ports and can never hold more entries than the
248+
// range has ports, however many distinct keys the worker sees.
249+
s := &backendSupervisor{
250+
processes: map[string]*backendProcess{},
251+
minPort: 50051,
252+
nextPort: 50051,
253+
maxPort: 50052,
254+
portQuarantine: time.Nanosecond,
255+
}
256+
257+
for i := 0; i < 50; i++ {
258+
key := "model" + string(rune('a'+i%26)) + "#0"
259+
port, err := s.allocatePort(key)
260+
Expect(err).NotTo(HaveOccurred())
261+
s.releasePortForKey(key, port)
262+
time.Sleep(time.Millisecond)
263+
}
264+
265+
Expect(len(s.portAffinity)).To(BeNumerically("<=", 2))
266+
})
267+
})
268+
})

core/services/worker/port_quarantine_test.go

Lines changed: 16 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,17 @@ var _ = Describe("Backend port quarantine", func() {
3535
Expect(s.finishBackendStop("model#0", bp, nil)).To(Succeed())
3636

3737
Expect(s.freePorts).To(BeEmpty())
38-
Expect(s.allocatePort()).NotTo(Equal(50051))
38+
Expect(mustAllocate(s, "next#0")).NotTo(Equal(50051))
3939
})
4040

41-
It("returns the port to the free pool once the quarantine expires", func() {
42-
// The quarantine must not leak ports: nextPort only ever grows, so a
43-
// port that never comes back is a permanent loss of allocator range.
41+
It("returns the port to its own backend once the quarantine expires", func() {
42+
// The quarantine must not leak ports: a port that never comes back
43+
// is a permanent loss of allocator range.
44+
//
45+
// It comes back to the key that released it, not to a global pool —
46+
// that is the affinity rule in allocatePort, which is what keeps a
47+
// stale controller row for this key from ever resolving to a
48+
// different model's backend.
4449
bp := &backendProcess{port: 50051}
4550
s := &backendSupervisor{
4651
processes: map[string]*backendProcess{"model#0": bp},
@@ -51,7 +56,7 @@ var _ = Describe("Backend port quarantine", func() {
5156
Expect(s.finishBackendStop("model#0", bp, nil)).To(Succeed())
5257
time.Sleep(5 * time.Millisecond)
5358

54-
Expect(s.allocatePort()).To(Equal(50051))
59+
Expect(mustAllocate(s, "model#0")).To(Equal(50051))
5560
})
5661
})
5762

@@ -70,7 +75,7 @@ var _ = Describe("Backend port quarantine", func() {
7075
s.releaseBackendStart("model#0", bp)
7176

7277
Expect(s.freePorts).To(BeEmpty())
73-
Expect(s.allocatePort()).To(Equal(50060))
78+
Expect(mustAllocate(s, "next#0")).To(Equal(50060))
7479
})
7580
})
7681

@@ -85,8 +90,8 @@ var _ = Describe("Backend port quarantine", func() {
8590
s.releasePort(50051)
8691
time.Sleep(5 * time.Millisecond)
8792

88-
Expect(s.allocatePort()).To(Equal(50051))
89-
Expect(s.allocatePort()).To(Equal(50060))
93+
Expect(mustAllocate(s, "next#0")).To(Equal(50051))
94+
Expect(mustAllocate(s, "next#0")).To(Equal(50060))
9095
Expect(s.nextPort).To(Equal(50061))
9196
})
9297

@@ -100,8 +105,8 @@ var _ = Describe("Backend port quarantine", func() {
100105
s.releasePort(50051)
101106
s.releasePort(50052)
102107

103-
Expect(s.allocatePort()).To(Equal(50060))
104-
Expect(s.allocatePort()).To(Equal(50061))
108+
Expect(mustAllocate(s, "next#0")).To(Equal(50060))
109+
Expect(mustAllocate(s, "next#0")).To(Equal(50061))
105110
})
106111

107112
It("applies the default quarantine when none is configured", func() {
@@ -114,7 +119,7 @@ var _ = Describe("Backend port quarantine", func() {
114119

115120
s.releasePort(50051)
116121

117-
Expect(s.allocatePort()).To(Equal(50060))
122+
Expect(mustAllocate(s, "next#0")).To(Equal(50060))
118123
})
119124
})
120125
})

core/services/worker/registration.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,32 @@ func (cfg *Config) effectiveBasePort() int {
3838
return 50051
3939
}
4040

41+
// effectiveMaxPort returns the last port the gRPC backend allocator may hand
42+
// out. The range is [basePort, maxPort]; its width is the number of backend
43+
// processes this worker can run concurrently, minus whatever the port
44+
// quarantine is holding at the time.
45+
//
46+
// An unset, non-positive, or out-of-order value falls back to 65535 — the
47+
// historical (unbounded) behaviour clamped to something bindable.
48+
// Misconfiguring this must not shrink the range to nothing and wedge every
49+
// backend start on the worker.
50+
func (cfg *Config) effectiveMaxPort(basePort int) int {
51+
if cfg.GRPCMaxPort <= 0 {
52+
return defaultMaxPort
53+
}
54+
if cfg.GRPCMaxPort > defaultMaxPort {
55+
xlog.Warn("Configured gRPC max port is above the highest TCP port; clamping",
56+
"configured", cfg.GRPCMaxPort, "max", defaultMaxPort)
57+
return defaultMaxPort
58+
}
59+
if cfg.GRPCMaxPort < basePort {
60+
xlog.Warn("Configured gRPC max port is below the base port; ignoring it and using the full range",
61+
"configured", cfg.GRPCMaxPort, "basePort", basePort, "max", defaultMaxPort)
62+
return defaultMaxPort
63+
}
64+
return cfg.GRPCMaxPort
65+
}
66+
4167
// advertiseAddr returns the address the frontend should use to reach this node.
4268
func (cfg *Config) advertiseAddr() string {
4369
if cfg.AdvertiseAddr != "" {

0 commit comments

Comments
 (0)