server: take the state lock around model unload - #402
Conversation
4e9cecb to
c853bb7
Compare
|
The endpoint was introduced in PR #197 for server-side model memory management. I don’t think we should gate it behind |
handle_unload_models read models_ and model_index_ without holding models_mutex_. A concurrent /v1/models/load appends to models_, and the reallocation invalidates an iterator or pointer another thread is walking. The handler now resolves each id under the lock and keeps only the LoadedModel* (stable, because the vector holds unique_ptr), and the unload-all path snapshots the resident pointers under the lock instead of iterating models_ directly. live_ingest_limits read the same two members unlocked and now takes the lock. Unload-all waited on busy.acquire(0, id), an unbounded wait against exactly the wedged run an operator calls this route to clear. It now uses acquire_model_run so the configured timeout applies, as the per-id path already did. The routes stay reachable without --ui-management, as they were: the server does not implement access control, and deployments are expected to keep these endpoints away from untrusted clients. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ATa5YkLUPMDPRL7w1gCo9p
c853bb7 to
1712606
Compare
|
That is fair, and I have dropped the gate. Thanks for the context on #197 — I had read the asymmetry with I have force-pushed a version that keeps only the concurrency fix, and retitled it accordingly. What remains is the data race:
Happy to split the |
handle_unload_modelsandhandle_unload_all_modelsread the loaded-model state without holdingmodels_mutex_, and unload-all waited on the busy guard with no timeout. This takes the lock and applies the configured timeout. Access control is unchanged: both routes stay reachable exactly as they are today.The race
models_is astd::vector<std::unique_ptr<LoadedModel>>andmodel_index_maps id to index./v1/models/loadappends tomodels_undermodels_mutex_; that append can reallocate the vector.handle_unload_modelslooked the id up inmodel_index_and dereferencedmodels_[it->second]with no lock. A concurrent load can reallocate between the lookup and the dereference.handle_unload_all_modelsiteratedmodels_directly, so a reallocation mid-iteration invalidates the iterator.live_ingest_limitsread both members unlocked on the request path.The fix resolves each id under the lock and keeps only the
LoadedModel *. That pointer stays valid across a reallocation because the vector holdsunique_ptr— the pointee does not move. Unload-all snapshots the resident pointers under the lock and then works from the snapshot.live_ingest_limitstakes the lock for its two reads.The unbounded wait
Unload-all called
model->busy.acquire(0, model->config.id), which waits forever. An operator calling this route is usually trying to clear a wedged run, so waiting on that run indefinitely is the wrong behaviour. It now callsacquire_model_run(*model, std::nullopt), which applies the configured busy timeout — the per-id path already did this.Verification
Built and run on macOS with the Metal backend, started without
--ui-management:Both unload routes behave exactly as they do on
mainfor every caller.ctestpasses 40/40.Note
An earlier revision of this PR also gated the two unload routes behind
--ui-management. That was wrong for this project — the server does not implement access control by design, and deployments are expected to keep these endpoints away from untrusted clients. The gate is gone; only the concurrency fix remains.