Skip to content

Eval bug: Router mode --models-max not enforced under concurrent requests (TOCTOU race in unload_lru) #20137

Description

@BenRacicot

Name and Version

Name and Version:

  • b8189
  • macOS 15.5, Apple Silicon (M4 Max, 64GB unified memory)
  • Operating systems: macOS
  • GGML backends: Metal

This took a lot to find and repro, I do hope it's very helpful and thanks for looking into it.

Problem description & steps to reproduce:

Two related issues with router mode memory management that make --models-max unreliable on
memory-constrained systems.

Issue 1: --models-max race condition with concurrent requests

With --models-max 2 and 5 GGUF models in the directory, sending concurrent requests for different models loads 3+ models simultaneously, ignoring the limit entirely. After all requests complete, the loaded count never settles back to 2.

Root cause

In server-models.cpp, the load() method calls unload_lru() outside the mutex, then acquires the lock afterward:

void server_models::load(const std::string &name) {
    // ...
    unload_lru();                          // ← checks count WITHOUT lock
    std::lock_guard<std::mutex> lk(mutex); // ← lock acquired AFTER check
    // proceeds to load without re-checking capacity
}

unload_lru() checks count_active >= models_max without synchronization. When multiple requests arrive concurrently, they all observe count_active < models_max and all proceed to load, exceeding the limit.

This is a classic TOCTOU (time-of-check-time-of-use) race.

Steps to reproduce

# 1. Place 5+ GGUF models in a directory
# 2. Start router with limit of 2
llama-server --models-dir /path/to/models --models-max 2 --port 8086 -ngl 99

# 3. Send 5 concurrent requests for different models
for model in ModelA ModelB ModelC ModelD ModelE; do
  curl -s http://127.0.0.1:8086/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d
"{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5}" & done wait

# 4. Check loaded models — expect 2, observe 3+
curl -s http://127.0.0.1:8086/v1/models | jq '.data[] | select(.status.value == "loaded") | .id'

Observed behavior

Time after requests Child processes Peak RSS Models loaded
5s 3 15.5 GB 3
15s 4 18.2 GB 3
60s 4 12.5 GB 3 (never drops to 2)

Sequential requests work correctly — the limit is enforced when requests arrive one at a time.

Expected behavior

The router should serialize or gate model loads so that at most --models-max models are loaded simultaneously. Requests for unloaded models should queue when at capacity, not bypass the limit.

Suggested fix

Move unload_lru() inside the critical section, or re-check capacity after acquiring the lock:

void server_models::load(const std::string &name) {
  if (!has_model(name)) {
    throw std::runtime_error("model name=" + name + " is not found");
  }
  std::lock_guard<std::mutex> lk(mutex); // ← lock FIRST
  unload_lru();                          // ← then check/evict under lock
  // proceed to load
}

Issue 2: -cram budget duplicated per child process, not shared

The -cram value is passed to each child process independently. With --models-max 2 -cram 15000, the intended total cache budget is 15 GB, but the actual allocation is 2 × 15 GB = 30 GB.

Visible in the /v1/models response — each model entry's status.args shows the full --cache-ram
value:

{
  "id": "SomeModel-Q4_K_M",
  "status": {
    "value": "loaded",
    "args": ["...", "--cache-ram", "15000", "..."]
  }
}

This compounds with Issue 1: when 3+ models load instead of 2, each with the full cache budget, peak memory far exceeds what was intended.

Expected: -cram should either be divided across --models-max children automatically, or this behavior should be clearly documented so callers can pass cram / models_max themselves.


Combined impact

On a 64 GB Apple Silicon Mac with 5 models and --models-max 2 -cram 15000:

  • Expected peak: ~6 GB (2 models + shared 15 GB cache budget)
  • Actual peak: 18+ GB (3+ models loaded, each with 15 GB cache)

This causes severe memory pressure and makes router mode unreliable for its intended purpose of limiting resource usage on unified-memory systems.

Operating systems

Mac

GGML backends

Metal

Hardware

M2 Max / 32GB

Models

Any combination can be used to see the issue stated. I was working with Qwen 3 models during repro.

Problem description & steps to reproduce

Steps to reproduce

# 1. Place 5+ GGUF models in a directory
# 2. Start router with limit of 2
llama-server --models-dir /path/to/models --models-max 2 --port 8086 -ngl 99

# 3. Send 5 concurrent requests for different models
for model in ModelA ModelB ModelC ModelD ModelE; do
  curl -s http://127.0.0.1:8086/v1/chat/completions \
    -H "Content-Type: application/json" \
    -d
"{\"model\":\"$model\",\"messages\":[{\"role\":\"user\",\"content\":\"hi\"}],\"max_tokens\":5}" & done wait

# 4. Check loaded models — expect 2, observe 3+
curl -s http://127.0.0.1:8086/v1/models | jq '.data[] | select(.status.value == "loaded") | .id'

First Bad Commit

No response

Relevant log output

I don't have logs but ram usage is the key issue. If you require logs please let me know.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions