voice_audio_multi/tests/unittests/test_server_busy_guard.cpp
Fabio c36510c21e
Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

252 lines
9 KiB
C++

// Behavioral tests for the model busy guard.
//
// The central claim these tests pin down is the one the guard exists to make:
// with the guard disabled (timeout 0) the server behaves exactly as it did before
// -- callers queue behind a wedged inference forever and every worker thread is
// consumed -- and with a positive timeout those same callers fail fast instead.
// Rather than asserting on the code path, each test drives real threads against a
// real BusyGuard whose holder never releases, which is what a wedged CUDA call
// looks like from userspace.
#include "busy_guard.h"
#include <atomic>
#include <chrono>
#include <future>
#include <iostream>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
namespace {
using namespace std::chrono_literals;
void require(bool condition, const std::string & message) {
if (!condition) {
throw std::runtime_error(message);
}
}
// A run that never finishes, standing in for an inference wedged inside the GPU
// driver. The holder thread parks until `release` is set, so the guard stays taken
// for as long as the test needs.
class WedgedRun {
public:
explicit WedgedRun(minitts::server::BusyGuard & guard) {
thread_ = std::thread([this, &guard] {
auto lock = guard.acquire(0, "wedged");
held_.store(true);
while (!release_.load()) {
std::this_thread::sleep_for(1ms);
}
});
while (!held_.load()) {
std::this_thread::sleep_for(1ms);
}
}
// Let the wedged run finish. Tests must call this before their std::async futures
// go out of scope: ~future blocks until its task completes, and locals destruct in
// reverse declaration order, so an implicit cleanup would join the waiters while
// this run still holds the guard -- deadlocking the test itself.
void release() {
if (thread_.joinable()) {
release_.store(true);
thread_.join();
}
}
~WedgedRun() { release(); }
private:
std::thread thread_;
std::atomic<bool> held_{false};
std::atomic<bool> release_{false};
};
// Is this caller still stuck waiting after `patience`? Returns true when the future
// has not resolved -- i.e. the thread is parked on the lock, exactly the pile-up the
// guard is meant to prevent.
template <typename T>
bool still_blocked(std::future<T> & future, std::chrono::milliseconds patience) {
return future.wait_for(patience) == std::future_status::timeout;
}
// timeout 0 must reproduce the original std::mutex behavior exactly: a caller
// arriving behind a wedged run waits, and keeps waiting, with no way out.
void test_disabled_guard_queues_forever() {
minitts::server::BusyGuard guard;
WedgedRun wedged(guard);
std::atomic<bool> acquired{false};
auto waiter = std::async(std::launch::async, [&] {
auto lock = guard.acquire(0, "model"); // guard disabled
acquired.store(true);
return true;
});
require(
still_blocked(waiter, 300ms),
"with busy_timeout_ms=0 a caller must queue behind the wedged run (original behavior)");
require(!acquired.load(), "the queued caller must not have entered the model");
// The waiter is still parked at this point. Releasing the wedged run lets it
// through so the test can finish -- in the real server nothing performs this
// release, which is precisely the bug: the thread is gone until the process
// restarts.
wedged.release();
require(waiter.get(), "the waiter proceeds only once the wedged run releases");
require(acquired.load(), "the queued caller enters the model after the release");
}
// The system-stuck claim: with the guard disabled, every worker that arrives is
// consumed, so a bounded thread pool is fully exhausted and the server stops
// serving -- including requests for other, healthy models.
void test_disabled_guard_exhausts_all_workers() {
minitts::server::BusyGuard guard;
WedgedRun wedged(guard);
constexpr int kWorkers = 8;
std::atomic<int> entered{0};
std::vector<std::future<void>> workers;
workers.reserve(kWorkers);
for (int i = 0; i < kWorkers; ++i) {
workers.push_back(std::async(std::launch::async, [&] {
auto lock = guard.acquire(0, "model");
entered.fetch_add(1);
}));
}
for (auto & worker : workers) {
require(
still_blocked(worker, 100ms),
"every worker must be stuck behind the wedged run when the guard is disabled");
}
require(entered.load() == 0, "no worker may enter the model while it is wedged");
wedged.release(); // drain the parked workers so the test can exit
for (auto & worker : workers) {
worker.get();
}
require(entered.load() == kWorkers, "the parked workers drain once the model frees up");
}
// With a positive timeout the same wedged run no longer captures callers: each one
// gives up and reports busy, so the worker returns to the pool.
void test_enabled_guard_fails_fast_instead_of_queuing() {
minitts::server::BusyGuard guard;
WedgedRun wedged(guard);
constexpr int kWorkers = 8;
std::atomic<int> busy_errors{0};
std::vector<std::future<void>> workers;
workers.reserve(kWorkers);
for (int i = 0; i < kWorkers; ++i) {
workers.push_back(std::async(std::launch::async, [&] {
try {
auto lock = guard.acquire(50, "model");
require(false, "acquire must not succeed while the model is wedged");
} catch (const minitts::server::ServerBusyError &) {
busy_errors.fetch_add(1);
}
}));
}
// Record the verdict before asserting: if the guard regressed, the workers are
// parked on the lock and their futures would block on destruction, hanging the
// test instead of reporting. Releasing the wedged run first frees them either way.
bool all_returned = true;
for (auto & worker : workers) {
if (still_blocked(worker, 2000ms)) {
all_returned = false;
}
}
wedged.release();
for (auto & worker : workers) {
worker.get();
}
require(all_returned, "with a positive timeout no worker may stay parked on the lock");
require(
busy_errors.load() == kWorkers,
"every worker must surface ServerBusyError rather than hang");
}
// Once the holder has overrun, later arrivals skip the wait entirely -- they must
// not each burn another full timeout window before reporting busy.
void test_overrun_holder_fails_without_waiting() {
minitts::server::BusyGuard guard;
WedgedRun wedged(guard);
// Let the holder run past a 50 ms bound.
std::this_thread::sleep_for(120ms);
const auto started = std::chrono::steady_clock::now();
bool threw = false;
try {
auto lock = guard.acquire(50, "model");
} catch (const minitts::server::ServerBusyError &) {
threw = true;
}
const auto elapsed = std::chrono::steady_clock::now() - started;
require(threw, "an already-overrun holder must be reported as busy");
require(
elapsed < 40ms,
"the fail-fast path must not wait out another timeout window");
}
// The guard must not leave a stale timestamp behind: after a normal run completes,
// the next caller acquires immediately rather than inheriting the previous run's
// elapsed time and being wrongly rejected.
void test_guard_is_reusable_after_a_normal_run() {
minitts::server::BusyGuard guard;
{
auto lock = guard.acquire(50, "model");
std::this_thread::sleep_for(80ms); // longer than the timeout, but legitimate
}
// A completed run -- even one that outlived the timeout -- must not poison the guard.
auto lock = guard.acquire(50, "model");
}
// The same must hold when a run ends by throwing.
void test_guard_is_reusable_after_a_failed_run() {
minitts::server::BusyGuard guard;
try {
auto lock = guard.acquire(50, "model");
throw std::runtime_error("inference failed");
} catch (const std::runtime_error &) {
}
auto lock = guard.acquire(50, "model");
}
// An idle model must be entered without delay regardless of the bound.
void test_idle_model_acquires_immediately() {
minitts::server::BusyGuard guard;
const auto started = std::chrono::steady_clock::now();
auto lock = guard.acquire(5000, "model");
require(
std::chrono::steady_clock::now() - started < 100ms,
"an idle model must not wait for the timeout");
}
} // namespace
int main() {
try {
test_disabled_guard_queues_forever();
test_disabled_guard_exhausts_all_workers();
test_enabled_guard_fails_fast_instead_of_queuing();
test_overrun_holder_fails_without_waiting();
test_guard_is_reusable_after_a_normal_run();
test_guard_is_reusable_after_a_failed_run();
test_idle_model_acquires_immediately();
} catch (const std::exception & error) {
std::cerr << error.what() << '\n';
return 1;
}
std::cout << "server_busy_guard_test passed\n";
return 0;
}