Skip to content

fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports#10968

Merged
mudler merged 1 commit into
masterfrom
fix/port-allocator-bounds
Jul 19, 2026
Merged

fix(worker): bound the gRPC port allocator and stop leaking dead backends' ports#10968
mudler merged 1 commit into
masterfrom
fix/port-allocator-bounds

Conversation

@localai-bot

Copy link
Copy Markdown
Collaborator

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 to freePorts, 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 StoppedProcessKeys back to the controller (the eager row removal added in #10956). So a stale NodeModel row 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 probeHealth verifies liveness rather than identity, so the request is silently misrouted.

Fixes

Bounded allocator. Explicit [basePort, LOCALAI_GRPC_MAX_PORT] range. Exhaustion returns ErrNoFreePort naming 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":

no free gRPC port in range: 50051-50150 is fully consumed by 100 running
backend(s) and 12 port(s) still in quarantine; raise LOCALAI_GRPC_MAX_PORT to
widen the range

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) 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 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

  • Unsolicited "process died" event. No persistent controller-side subscriber to node-published subjects exists to hang it on (the only Subscribe in unloader.go is 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.
  • Quarantine plus documentation. Not defensible once the leak is fixed: 15s against a ~45s reaper that can be disabled is a shorter window on a permanent stale row, not a mitigation.
  • Identity-verifying probeHealth (distributed: worker recycles a stopped backend's gRPC port, allowing a silent misroute #10952's own Option 2). StatusResponse carries only state and memory — no model identity, and HealthMessage is 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, probeHealth short-circuits on a 30s probeCache keyed nodeID|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 including tests/e2e/: referenced only within core/services/worker. No e2e spec pins it.
  • One pre-existing spec changed meaning deliberately: "returns the port to the free pool once the quarantine expires" assumed a global pool; under affinity the port returns to its own key. Re-expressed as "returns the port to its own backend" — same invariant (the port is not lost), correct owner. Flagged explicitly because it is a test edited to pass; what independently guards reachability now is the steal-under-pressure spec and the crash-loop spec.
  • LOCALAI_GRPC_MAX_PORT is new user-visible surface, documented in docs/content/features/distributed-mode.md with 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

…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
mudler force-pushed the fix/port-allocator-bounds branch from cd3dcfa to e6f7501 Compare July 19, 2026 23:12
@mudler
mudler enabled auto-merge (squash) July 19, 2026 23:22
@mudler
mudler merged commit e55cc3e into master Jul 19, 2026
67 checks passed
@mudler
mudler deleted the fix/port-allocator-bounds branch July 19, 2026 23:56
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

worker: gRPC port allocator has no upper bound and never wraps distributed: worker recycles a stopped backend's gRPC port, allowing a silent misroute

2 participants