Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion src/models/adapters.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,32 @@ Adapter::Adapter(const char* adapter_file_path, Ort::Allocator* allocator)
: adapter_{OrtLoraAdapter::Create(fs::path(adapter_file_path).c_str(), *allocator)} {}

const OrtLoraAdapter* Adapter::AcquireRef() {
// Private; only callable by Adapters (friend), which holds Adapters::mutex_
Comment thread
kunal-vaishnavi marked this conversation as resolved.
// and therefore serializes all access to ref_count_.
ref_count_++;

return adapter_.get();
}

void Adapter::ReleaseRef() {
// Private; only callable by Adapters (friend), which holds Adapters::mutex_.
ref_count_--;
if (ref_count_ < 0) {
// Restore invariant so a caller catching the exception doesn't leave the
// counter in a negative state that would trip later releases too.
ref_count_++;
throw std::runtime_error("Adapter ref count went negative.");
}
}

int32_t Adapter::RefCount() const {
// Private; only callable by Adapters (friend), which holds Adapters::mutex_.
return ref_count_;
}

Adapters::Adapters(const Model* model) : model_{model} {}

void Adapters::LoadAdapter(const char* adapter_file_path, const std::string& adapter_name) {
std::lock_guard<std::mutex> lock(mutex_);
if (adapters_.find(adapter_name) != adapters_.end()) {
throw std::runtime_error("Adapter already loaded: " + std::string{adapter_name});
}
Expand All @@ -40,11 +47,16 @@ void Adapters::LoadAdapter(const char* adapter_file_path, const std::string& ada
}

void Adapters::UnloadAdapter(const std::string& adapter_name) {
std::lock_guard<std::mutex> lock(mutex_);
auto adapter = adapters_.find(adapter_name);
if (adapter == adapters_.end()) {
throw std::runtime_error("Adapter not found: " + std::string{adapter_name});
Comment thread
apsonawane marked this conversation as resolved.
}

// Check-and-erase must happen atomically with respect to AcquireAdapter /
// ReleaseAdapter, which also acquire mutex_. This closes the TOCTOU window
// where another thread could AcquireRef() between the RefCount() check and
// the erase(), producing a use-after-free.
if (adapter->second->RefCount() > 0) {
throw std::runtime_error("Adapter still in use: " + std::string{adapter_name});
}
Expand All @@ -53,6 +65,7 @@ void Adapters::UnloadAdapter(const std::string& adapter_name) {
}

const OrtLoraAdapter* Adapters::AcquireAdapter(const std::string& adapter_name) {
std::lock_guard<std::mutex> lock(mutex_);
auto adapter = adapters_.find(adapter_name);
if (adapter == adapters_.end()) {
throw std::runtime_error("Adapter not found: " + std::string{adapter_name});
Expand All @@ -62,6 +75,7 @@ const OrtLoraAdapter* Adapters::AcquireAdapter(const std::string& adapter_name)
}

void Adapters::ReleaseAdapter(const std::string& adapter_name) {
std::lock_guard<std::mutex> lock(mutex_);
auto adapter = adapters_.find(adapter_name);
if (adapter == adapters_.end()) {
throw std::runtime_error("Adapter not found: " + std::string{adapter_name});
Expand Down
15 changes: 14 additions & 1 deletion src/models/adapters.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,21 @@ struct Adapter {

Adapter(const char* adapter_file_path, Ort::Allocator* allocator);

private:
Comment thread
kunal-vaishnavi marked this conversation as resolved.
// AcquireRef/ReleaseRef/RefCount are intentionally private so that all
// access to ref_count_ is funneled through Adapters, which holds
// Adapters::mutex_. Exposing them publicly would make it easy for future
// call sites to bypass the mutex and reintroduce the data race / TOCTOU
// window between RefCount() and container erasure in
// Adapters::UnloadAdapter().
friend struct Adapters;

const OrtLoraAdapter* AcquireRef();

void ReleaseRef();

int32_t RefCount() const;

private:
int32_t ref_count_{};
std::unique_ptr<OrtLoraAdapter> adapter_;
};
Expand All @@ -41,6 +49,11 @@ struct Adapters : std::enable_shared_from_this<Adapters>, ExternalRefCounted<Ada

private:
const Model* model_;
// Serializes all access to adapters_ and to per-Adapter ref counts so that
// load/unload/acquire/release cannot race. Without this, the check-then-erase
// pattern in UnloadAdapter (and concurrent std::unordered_map mutation) is a
// use-after-free hazard.
mutable std::mutex mutex_;
std::unordered_map<std::string, std::unique_ptr<Adapter>> adapters_;
};

Expand Down
64 changes: 64 additions & 0 deletions test/c_api_tests.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1243,6 +1243,70 @@ TEST(CAPITests, AdaptersTestMultipleAdapters) {
adapters->UnloadAdapter("adapter_a");
adapters->UnloadAdapter("adapter_b");
}

// Regression test for the concurrency use-after-free / data race in the
// adapter lifecycle. Prior to serializing Adapters ops with a mutex,
// concurrent LoadAdapter/UnloadAdapter/SetActiveAdapter calls could race on
// Adapter::ref_count_ and on the underlying unordered_map, producing lost
// updates and a TOCTOU window where UnloadAdapter would erase an adapter
// that another thread had just acquired.
//
// This test hammers the Adapters API from multiple threads. It is not
// deterministic about which operations succeed (a concurrent UnloadAdapter
// may legitimately throw "Adapter still in use" or "Adapter not found",
// and a concurrent LoadAdapter of the same name may throw "already loaded")
// but under TSAN/ASAN and in stress mode it reliably catches the pre-fix
// races. Here we simply assert that no thread crashes or leaves the
// Adapters map in an inconsistent state.
TEST(CAPITests, AdaptersConcurrentLoadUnload) {
auto model = OgaModel::Create(MODEL_PATH "multiple_adapters");
auto adapters = OgaAdapters::Create(*model);

constexpr int kIterations = 50;
constexpr int kThreadsPerRole = 4;

const char* adapter_path_a = MODEL_PATH "multiple_adapters/adapter_0.onnx_adapter";
const char* adapter_path_b = MODEL_PATH "multiple_adapters/adapter_1.onnx_adapter";

auto swallow = [](auto&& fn) {
try {
fn();
} catch (const std::exception&) {
// Concurrent load/unload can legitimately throw (already loaded /
// not found / still in use). We only care that state stays consistent.
}
};

std::vector<std::thread> threads;
threads.reserve(kThreadsPerRole * 2);

for (int t = 0; t < kThreadsPerRole; ++t) {
threads.emplace_back([&] {
for (int i = 0; i < kIterations; ++i) {
swallow([&] { adapters->LoadAdapter(adapter_path_a, "adapter_a"); });
swallow([&] { adapters->LoadAdapter(adapter_path_b, "adapter_b"); });
}
});
threads.emplace_back([&] {
for (int i = 0; i < kIterations; ++i) {
swallow([&] { adapters->UnloadAdapter("adapter_a"); });
swallow([&] { adapters->UnloadAdapter("adapter_b"); });
}
});
}

for (auto& th : threads) th.join();

// Drain any adapters left loaded so we end in a known state. These may
// throw "not found" depending on which thread won the last unload; that's
// fine, we just want to prove the API remains usable and consistent.
swallow([&] { adapters->UnloadAdapter("adapter_a"); });
swallow([&] { adapters->UnloadAdapter("adapter_b"); });

// After draining, a fresh load/unload cycle must still succeed cleanly.
adapters->LoadAdapter(adapter_path_a, "adapter_a");
adapters->UnloadAdapter("adapter_a");
}
#endif // TEST_PHI2 && !USE_DML

void CheckResult(OgaResult* result) {
Expand Down
Loading