From c1b911654a581662c19eb51829705e4ed7020673 Mon Sep 17 00:00:00 2001 From: Ben Racicot <1815385+BenRacicot@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:16:05 -0400 Subject: [PATCH] server: fix router mode deadlock on child crash and TOCTOU race in models_max (#20763) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two bugs in `server_models::load()` that affect router mode reliability: **Bug 1: Deadlock when child process crashes** When a child process is killed (e.g., SIGKILL from OS code signature validation), the monitoring thread deadlocks on `stopping_thread.join()` because the stopping_thread's wait predicate (`is_stopping`) is never satisfied — the model name was never inserted into `stopping_models`. `update_status()` is never reached and the model stays stuck in LOADING state permanently. Fix: extend the stopping_thread's wait predicate to also wake when the child process is no longer alive (`!subprocess_alive()`). When woken by a dead child, the thread skips the shutdown sequence and returns immediately. The original `stopping_models.erase()` logic is preserved for normal unloads. **Bug 2: TOCTOU race bypasses `--models-max` (ref #20137)** `unload_lru()` is called outside the mutex, then `load()` acquires the lock afterward. Under concurrent requests, multiple threads observe capacity and all proceed to load, exceeding the limit. Fix: re-check capacity under the lock after `unload_lru()` returns. If another thread filled the slot in the window between `unload_lru()` and the lock acquisition, reject with an error instead of silently exceeding the limit. --- tools/server/server-models.cpp | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/tools/server/server-models.cpp b/tools/server/server-models.cpp index c13d48a363..4ac55cd158 100644 --- a/tools/server/server-models.cpp +++ b/tools/server/server-models.cpp @@ -539,6 +539,22 @@ void server_models::load(const std::string & name) { return; } + // Re-check capacity under the lock to prevent concurrent loads from + // exceeding models_max. Without this, the window between unload_lru() + // releasing its lock and this lock_guard acquiring allows multiple + // threads to each observe capacity and all proceed to load. + if (base_params.models_max > 0) { + size_t count_active = 0; + for (const auto & m : mapping) { + if (m.second.meta.is_active()) { + count_active++; + } + } + if (count_active >= (size_t)base_params.models_max) { + throw std::runtime_error("model limit reached, try again later"); + } + } + // prepare new instance info instance_t inst; inst.meta = meta; @@ -606,13 +622,20 @@ void server_models::load(const std::string & name) { }); std::thread stopping_thread([&]() { - // thread to monitor stopping signal + // thread to monitor stopping signal OR child crash auto is_stopping = [this, &name]() { return this->stopping_models.find(name) != this->stopping_models.end(); }; + auto should_wake = [&]() { + return is_stopping() || !subprocess_alive(child_proc.get()); + }; { std::unique_lock lk(this->mutex); - this->cv_stop.wait(lk, is_stopping); + this->cv_stop.wait(lk, should_wake); + } + // child may have already exited (e.g. crashed) — skip shutdown sequence + if (!subprocess_alive(child_proc.get())) { + return; } SRV_INF("stopping model instance name=%s\n", name.c_str()); // send interrupt to child process