fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports#10968
Merged
Conversation
…ends' 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]
mudler
force-pushed
the
fix/port-allocator-bounds
branch
from
July 19, 2026 23:12
cd3dcfa to
e6f7501
Compare
mudler
enabled auto-merge (squash)
July 19, 2026 23:22
mudler
approved these changes
Jul 19, 2026
This was referenced Jul 20, 2026
mudler
added a commit
that referenced
this pull request
Jul 20, 2026
… coverage-ratchet fixes (#10989) * fix(http): make /readyz reflect startup readiness instead of always 200 /readyz was registered as a static handler returning 200 unconditionally, so it carried no information: it was green whenever it could be reached at all. Readiness could not distinguish "serving" from "still starting", and any future change that started the HTTP listener earlier would silently turn the probe into a lie. Track startup completion on the Application (atomic flag, flipped at the very end of New() on the success path only) and have the readiness handler consult it per request, returning 503 with a small JSON body while startup is in progress. A nil readiness source fails open so embedders keep the historical behaviour. /healthz is deliberately left readiness-independent. Liveness and readiness answer different questions, and failing liveness during a long preload makes an orchestrator restart the pod mid-download so the preload never finishes. This matters because since #10949 the startup preload materializes HuggingFace artifacts for managed backends: tens of GB for a large model (31 GB observed on a live cluster). Both probes stay in quietPaths and stay exempt from auth. Note the listener is still started only after New() returns, so today the not-ready state is not observable over HTTP. Moving the listener earlier is a separate, deliberate decision and is not made here. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(gitignore): anchor the mock-backend pattern so its source dir is traversable The bare `mock-backend` pattern matched the *directory* tests/e2e/mock-backend/, not just the binary built into it. Git will not descend into an ignored directory even for tracked files, so `git add tests/e2e/mock-backend/main.go` required -f. This was hit while working on #10970. Anchor it to the artifact's full path. The built binary stays ignored (it is also covered by tests/e2e/mock-backend/.gitignore) while the source directory becomes traversable again. Verified with `git check-ignore -v`: a new source file under tests/e2e/mock-backend/ is no longer ignored, and the binary produced by `make build-mock-backend` still is. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] * chore(coverage): raise the coverage ratchet from 48.5% to 54.2% The committed baseline had drifted well below reality: it still read 48.5% while a full instrumented run measures 54.2%. A stale-low baseline makes the gate meaningless — coverage could regress by more than 5 percentage points and still pass. Raising a ratchet is a deliberate act, not something to fold into an unrelated fix, so it gets its own commit. The headroom was earned by tests landed in #10946, #10947, #10948, #10949, #10956, #10967, #10968, #10970 and #10975. Measured with `make test-coverage` on this branch (the same instrumented run `make test-coverage-baseline` uses: ginkgo over ./pkg and ./core plus the in-process tests/e2e suite, --covermode=atomic, --coverpkg over core/... and pkg/..., generated protobuf excluded). The run completed with exit 0 and zero spec failures; the total was then written with the exact command the test-coverage-baseline target uses: go tool cover -func=coverage/coverage.out \ | awk '/^total:/{gsub(/%/,"",$NF); print $NF}' > coverage-baseline.txt Verified afterwards with scripts/coverage-check.sh, which reports OK. Note the measured figure includes the readiness specs added earlier on this branch, so it is a demonstrated floor rather than an estimate. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Claude:claude-opus-4-8 [Claude Code] --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
mudler
added a commit
that referenced
this pull request
Jul 21, 2026
…ll-clock (#11019) A 70 GB video checkpoint (longcat-video-avatar-1.5) could not be loaded on a distributed cluster. The request failed with HTTP 500 after 1499.98s - exactly the 25m00s cold-load ceiling - while staging was demonstrably healthy: 26 of 57 files and 39 GB transferred at a sustained ~26 MB/s, zero errors, no stalls. It was not wedged, it was killed by a timer. ModelLoadCeilingFor covers node selection, backend install, file staging and the remote LoadModel. Install and load carry their own budgets; staging was covered only by a FIXED 5-minute margin. But staging time is bytes over bandwidth, not a constant: 70 GB at 26 MB/s needs ~45m against a 25m ceiling, so the failure is deterministic for any sufficiently large model rather than a flake. Simply raising the constant moves the cliff to the next model size - the deployment target here is checkpoints of 600 GB and beyond. The ceiling's real purpose is that "a wedged worker can never pin the lock indefinitely". Progress, not elapsed time, is what distinguishes a wedged worker from a large one. The hold is now a deadline that extends whenever the transfer reports bytes and expires a 5-minute stall window after they stop: - A large model transferring fine continues, for hours if needed. - A worker that died mid-transfer still fails within the stall window. Progress is observed at byte level on the transfer itself, via the existing staging progress callback. Per-file completion would be too coarse - a single 600 GB shard would be indistinguishable from a stall for hours. The observation point is back-pressured by the socket, so it reflects the network rather than local disk reads. Observation is coarsened to one timer touch per stall/20 so the per-read callback stays cheap. The base budget (unchanged, and still derived from the install and load timeouts) continues to cover the steps that report no progress, so LOCALAI_NATS_MODEL_LOAD_TIMEOUT keeps working exactly as before. An absolute cap of 24h bounds the hold even while progress keeps arriving, so a peer trickling bytes forever cannot pin the advisory lock; 600 GB at the measured 26 MB/s is ~6.5h, so the cap sits far above any legitimate transfer. Also fixes the incoherent layering the same error exposed: the resumable upload carried a 1h retry budget nested inside the 25m ceiling, so the inner budget was unreachable and the message still blamed it ("failed after 1 attempts within 1h0m0s budget") while the 25m parent was the actual killer. The upload now adopts the caller's deadline when there is one, and applies its fixed budget only when nothing above bounded it - which also stops a fixed 1h from reintroducing the size cliff under the now-extendable parent. This is the successor to #10968, where a hardcoded 5-minute LoadModel gRPC timeout was replaced by this derived ceiling. Fixing the inner timeout exposed the outer ceiling as the new binding constraint. 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes two coupled defects in the worker's gRPC port allocator. They ship together because fixing the first without the second converts a benign resource leak into a silent misroute.
The port leak is the real driver of #10961
#10961 as filed estimates the allocator needs ~15,000 concurrent-peak allocations to reach 65535 — effectively unreachable. That estimate is wrong, because of a defect the issue does not mention:
startBackend's "process died unexpectedly" branch deleted the process map entry without releasing its port at all. Not tofreePorts, not to quarantine. Leaked outright — a fourth removal path against three release paths.A crash-looping backend therefore leaks one port per restart. A backend dying every 30s burns ~2,880 ports/day and walks 50051 → 65535 in about five days. That is a materially different severity from "concurrent-peak growth", and it is the realistic route to exhaustion on a long-lived worker.
Why #10952's residual had to be fixed in the same change
The unexpected-death path is the one teardown path with no request/reply to carry
StoppedProcessKeysback to the controller (the eager row removal added in #10956). So a staleNodeModelrow can survive it.Today that is harmless only because the port is leaked — never rebound, so probes fail and requests fail loudly. Fixing the leak is exactly what makes it live: once the port is recycled, a stale row resolves to a live listener belonging to a different backend, and
probeHealthverifies liveness rather than identity, so the request is silently misrouted.Fixes
Bounded allocator. Explicit
[basePort, LOCALAI_GRPC_MAX_PORT]range. Exhaustion returnsErrNoFreePortnaming the range, the live backend count, the quarantined count, and the knob to raise — instead of an unbindable integer surfacing as an opaque "backend won't start":Out-of-range config degrades rather than wedges: above 65535 clamps, below the base port falls back to the full range. Both spec'd, because an inverted range would otherwise take every backend start down with it.
Per-key port affinity. A released port is offered back to the process key that last held it before any other key. Process keys (
modelID#replica) andNodeModelrows (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 structurally, rather than by racing a 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 the previous owner's entry, keeping ownership injective over ports, so the affinity map can never exceed the range width regardless of key churn.
Affinity expires after 5 minutes. Ownership held forever would make every distinct model a worker has ever served consume a port permanently — the allocator would climb on distinct-key count rather than concurrency, then steal on every allocation while advising the operator to raise a ceiling that is not the constraint. With expiry, reaching the steal branch genuinely means the worker is out of concurrent capacity, so the warning's advice is correct when it fires.
Known residual — why this does not close #10952
The 5-minute window is set well above the per-model health reaper (~45s at the default cadence), which is what removes a stale row on the death path.
That reaper can be switched off entirely via
DisablePerModelHealthCheck. In that configuration a crash death leaves a row nothing removes (eager removal does not apply to this path), affinity lapses after 5 minutes, and the port becomes reusable by another key — reopening the misroute window.Narrow: it needs the reaper disabled, a crash death, and a subsequent allocation past the expiry. But it is a real gap, so #10952 stays open rather than being auto-closed here. The honest closure for that configuration is eager row removal on the death path, which needs a controller-side subscriber for an unsolicited worker event — deliberately out of scope here.
Alternatives rejected
Subscribeinunloader.gois a per-op progress sub torn down after each call). A new subject plus lifecycle wiring plus cross-version compat surface, for a crash-only path.probeHealth(distributed: worker recycles a stopped backend's gRPC port, allowing a silent misroute #10952's own Option 2).StatusResponsecarries onlystateandmemory— no model identity, andHealthMessageis empty, so there is nowhere to send an expected identity either. Needs a proto change, adoption across ~60 backends, and an extra RPC per probe. Worse,probeHealthshort-circuits on a 30sprobeCachekeyednodeID|addr, so in the exact failure window the check would not execute at all.Notes for reviewers
allocatePort's signature changed. Grepped the whole repo includingtests/e2e/: referenced only withincore/services/worker. No e2e spec pins it.LOCALAI_GRPC_MAX_PORTis new user-visible surface, documented indocs/content/features/distributed-mode.mdwith a "Backend gRPC port range" section explaining that range width caps concurrent backends and that the quarantine needs headroom above true concurrency.Closes #10961. Advances #10952 — see the residual above.
🤖 Generated with Claude Code