Skip to content

Commit b36cc3e

Browse files
mudlerclaude
andcommitted
fix(server): serialize engine-touching requests with a mutex + concurrency test
Post-review: cpp-httplib services requests on a worker-thread pool, but the LLMEngine (+ Scheduler + runner + KV cache) is stateful and NOT thread-safe. Two concurrent clients would interleave add_request/step and corrupt/crash the shared engine. Add a std::mutex in ApiServer guarding the create_completion/ create_chat_completion calls so requests process one at a time (correct T0 behavior; in-flight batching of multiple HTTP requests through one engine loop is a later async redesign). Add a 6-client concurrency test asserting all get well-formed 200s with identical greedy output (no cross-request state bleed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 23d9f2c commit b36cc3e

2 files changed

Lines changed: 67 additions & 0 deletions

File tree

src/vllm/entrypoints/openai/api_server.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
#include "vllm/entrypoints/openai/api_server.h"
55

66
#include <exception>
7+
#include <mutex>
78
#include <utility>
89

910
#include <httplib/httplib.h>
@@ -16,6 +17,13 @@ namespace vllm::entrypoints::openai {
1617
// Opaque httplib::Server (pimpl — keeps httplib.h out of api_server.h).
1718
struct ApiServer::Impl {
1819
httplib::Server server;
20+
// The LLMEngine (+ Scheduler + runner + KV cache) is stateful and NOT
21+
// thread-safe, but httplib services requests on a worker-thread pool. Serialize
22+
// every engine-touching request so two concurrent clients cannot interleave
23+
// add_request/step and corrupt the shared engine state. T0 = one request at a
24+
// time (correct, not concurrent-throughput); true in-flight batching of
25+
// multiple HTTP requests through one engine loop is a later async redesign.
26+
std::mutex engine_mutex;
1927
};
2028

2129
namespace {
@@ -75,6 +83,7 @@ ApiServer::DispatchResult ApiServer::handle_completions(
7583

7684
CompletionResult result;
7785
try {
86+
std::lock_guard<std::mutex> engine_lock(impl_->engine_mutex);
7887
result = completion_.create_completion(request);
7988
} catch (const std::exception& e) {
8089
return MakeError(500, "InternalServerError", e.what());
@@ -118,6 +127,7 @@ ApiServer::DispatchResult ApiServer::handle_chat_completions(
118127

119128
ChatCompletionResult result;
120129
try {
130+
std::lock_guard<std::mutex> engine_lock(impl_->engine_mutex);
121131
result = chat_.create_chat_completion(request);
122132
} catch (const std::exception& e) {
123133
return MakeError(500, "InternalServerError", e.what());

tests/vllm/entrypoints/openai/test_api_server.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -516,3 +516,60 @@ TEST_CASE("api_server: socket smoke — real HTTP requests over an ephemeral por
516516
h.server.stop();
517517
server_thread.join();
518518
}
519+
520+
// Concurrent clients must not race the stateful (non-thread-safe) LLMEngine:
521+
// httplib services requests on a worker-thread pool, so the api_server serializes
522+
// engine-touching requests with a mutex. This fires N concurrent completion
523+
// requests and asserts every one returns a well-formed 200 (no crash, no
524+
// corrupted/empty body). Without the mutex this races the shared scheduler/runner
525+
// /KV state (a TSan/ASan build would flag it; even plain builds can crash/garble).
526+
TEST_CASE("api_server: concurrent requests are serialized (no engine race)") {
527+
const HfConfig c = MakeConfig();
528+
const Qwen3_5MoeWeights w = MakeWeights(c);
529+
ServerHarness h(c, w, Fixture());
530+
531+
const int port = h.server.bind_to_any_port("127.0.0.1");
532+
REQUIRE(port > 0);
533+
std::thread server_thread([&h]() { h.server.serve(); });
534+
for (int i = 0; i < 500 && !h.server.is_running(); ++i)
535+
std::this_thread::sleep_for(std::chrono::milliseconds(2));
536+
REQUIRE(h.server.is_running());
537+
538+
constexpr int kClients = 6;
539+
std::vector<std::thread> clients;
540+
std::vector<int> statuses(kClients, -1);
541+
std::vector<std::string> texts(kClients);
542+
for (int i = 0; i < kClients; ++i) {
543+
clients.emplace_back([&, i]() {
544+
httplib::Client client("127.0.0.1", port);
545+
client.set_read_timeout(30, 0);
546+
auto res = client.Post(
547+
"/v1/completions",
548+
R"({"prompt":"hello","max_tokens":4,"temperature":0.0})",
549+
"application/json");
550+
if (res) {
551+
statuses[static_cast<size_t>(i)] = res->status;
552+
try {
553+
json j = json::parse(res->body);
554+
texts[static_cast<size_t>(i)] =
555+
j.at("choices").at(0).at("text").get<std::string>();
556+
} catch (...) {
557+
statuses[static_cast<size_t>(i)] = -2; // malformed body
558+
}
559+
}
560+
});
561+
}
562+
for (auto& t : clients) t.join();
563+
564+
for (int i = 0; i < kClients; ++i) {
565+
CHECK(statuses[static_cast<size_t>(i)] == 200);
566+
CHECK_FALSE(texts[static_cast<size_t>(i)].empty());
567+
}
568+
// All greedy on the same prompt → identical deterministic output, which also
569+
// confirms no cross-request state bleed.
570+
for (int i = 1; i < kClients; ++i)
571+
CHECK(texts[static_cast<size_t>(i)] == texts[0]);
572+
573+
h.server.stop();
574+
server_thread.join();
575+
}

0 commit comments

Comments
 (0)