Add extracted tools: CitrineOS, OpenOCPP, ShapeShifter

- CitrineOS core extracted (CSMS OCPP 2.0.1)
- OpenOCPP extracted (firmware OCPP 1.6J/2.0.1)
- ShapeShifter library installed (pip install -e)
- ShapeShifter specification extracted
- EVerest extracted

TODO updated with progress
This commit is contained in:
Eric F
2026-06-08 00:38:27 -04:00
parent 468cfeaa50
commit d398a6ced2
7326 changed files with 1177561 additions and 7 deletions

View File

@@ -0,0 +1,437 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include "gtest/gtest.h"
#include <everest/util/async/monitor.hpp>
#include <future>
#include <memory>
#include <string>
#include <thread>
#include <vector>
using namespace everest::lib::util;
struct SharedData {
int value = 0;
std::string name = "initial";
// Unique ID to track object identity across moves/swaps
long long id = std::chrono::duration_cast<std::chrono::nanoseconds>(
std::chrono::high_resolution_clock::now().time_since_epoch())
.count();
};
// --- Test Fixtures for Noexcept Checks ---
// 1. Type that is NOT nothrow-swappable (due to non-noexcept move constructor)
struct ThrowingMover {
int* ptr;
// Move Constructor: NOT noexcept (This is the key difference)
ThrowingMover(ThrowingMover&& other) noexcept(false) : ptr(std::exchange(other.ptr, nullptr)) {
if (!ptr)
throw std::runtime_error("simulated throw on move");
}
// Move Assignment: NOT noexcept
ThrowingMover& operator=(ThrowingMover&& other) noexcept(false) {
if (this != &other) {
std::swap(ptr, other.ptr);
}
return *this;
}
ThrowingMover() : ptr(new int(42)) {
}
~ThrowingMover() {
delete ptr;
}
ThrowingMover(const ThrowingMover&) = delete;
ThrowingMover& operator=(const ThrowingMover&) = delete;
// Define custom swap for ADL (must use the same non-noexcept status)
friend void swap(ThrowingMover& lhs, ThrowingMover& rhs) noexcept(false) {
std::swap(lhs.ptr, rhs.ptr);
}
};
// 2. Type that IS nothrow-swappable
struct NoThrowMover {
int* ptr;
// Move Constructor: IS noexcept
NoThrowMover(NoThrowMover&& other) noexcept : ptr(std::exchange(other.ptr, nullptr)) {
}
// Move Assignment: IS noexcept
NoThrowMover& operator=(NoThrowMover&& other) noexcept {
if (this != &other) {
std::swap(ptr, other.ptr);
}
return *this;
}
NoThrowMover() : ptr(new int(42)) {
}
~NoThrowMover() {
delete ptr;
}
NoThrowMover(const NoThrowMover&) = delete;
NoThrowMover& operator=(const NoThrowMover&) = delete;
// Define custom swap for ADL (must be noexcept)
friend void swap(NoThrowMover& lhs, NoThrowMover& rhs) noexcept {
std::swap(lhs.ptr, rhs.ptr);
}
};
// --- The Static Assert Tests ---
// We use basic static_asserts to verify the compiler's calculated noexcept status.
namespace NoexceptTests {
using namespace everest::lib::util;
template <typename T> using monitor = everest::lib::util::monitor<T>;
using M_NT = monitor<NoThrowMover>; // Monitor protecting the safe type
using M_T = monitor<ThrowingMover>; // Monitor protecting the unsafe type
// --- 1. Test against NoThrowMover (T is noexcept swappable) ---
// All move and swap operations on the monitor should be noexcept(true).
static_assert(std::is_nothrow_swappable_v<NoThrowMover>,
"Prerequisite 1 failed: NoThrowMover must be noexcept swappable.");
// Move Constructor: Should be noexcept(true)
static_assert(std::is_nothrow_move_constructible_v<M_NT>, "NT Test 1 failed: Move Constructor must be noexcept.");
// Member Swap: Should be noexcept(true)
static_assert(noexcept(std::declval<M_NT>().swap(std::declval<M_NT&>())),
"NT Test 2 failed: Member Swap must be noexcept.");
// Move Assignment: Should be noexcept(true)
static_assert(std::is_nothrow_move_assignable_v<M_NT>, "NT Test 3 failed: Move Assignment must be noexcept.");
// --- 2. Test against ThrowingMover (T is NOT noexcept swappable) ---
// All move and swap operations on the monitor should be noexcept(false).
static_assert(!std::is_nothrow_swappable_v<ThrowingMover>,
"Prerequisite 2 failed: ThrowingMover must NOT be noexcept swappable.");
// Move Constructor: Should be noexcept(false) (allows exceptions)
static_assert(!std::is_nothrow_move_constructible_v<M_T>, "T Test 1 failed: Move Constructor must NOT be noexcept.");
// Member Swap: Should be noexcept(false) (allows exceptions)
static_assert(!noexcept(std::declval<M_T>().swap(std::declval<M_T&>())),
"T Test 2 failed: Member Swap must NOT be noexcept.");
// Move Assignment: Should be noexcept(false) (allows exceptions)
static_assert(!std::is_nothrow_move_assignable_v<M_T>, "T Test 3 failed: Move Assignment must NOT be noexcept.");
} // namespace NoexceptTests
using namespace everest::lib::util;
class MonitorTest : public ::testing::Test {
protected:
monitor<SharedData> simple_monitor_;
monitor<std::unique_ptr<SharedData>> ptr_monitor_;
// A timed mutex enabled monitor::handle(timeout)
monitor<SharedData, std::timed_mutex> timed_mtx_monitor_;
// Time constants for tests
const std::chrono::milliseconds BLOCK_TIME = std::chrono::milliseconds(200);
const std::chrono::milliseconds SHORT_WAIT = std::chrono::milliseconds(10);
const std::chrono::milliseconds LONG_WAIT = std::chrono::milliseconds(300);
};
TEST_F(MonitorTest, SingleThreadedAccess) {
// Block 1: Access and Modify (Lock acquired by handle, then released)
{
// Acquire the handle (locks the mutex)
auto handle = simple_monitor_.handle();
// Access and modify the data using operator->
handle->value = 100;
handle->name = "updated";
// When 'handle' goes out of scope here, the lock is released (RAII).
}
// Block 2: Verify changes (Lock acquired, then released)
{
// Now acquiring the lock succeeds because it was released above.
auto handle_check = simple_monitor_.handle();
// Verify
EXPECT_EQ(100, handle_check->value);
EXPECT_EQ("updated", handle_check->name);
}
}
TEST_F(MonitorTest, PointerLikeAccessChaining) {
// Block 1: Initialization (Ensures the unique_ptr is created)
{
auto h = ptr_monitor_.handle();
*h = std::make_unique<SharedData>();
} // h is destroyed, lock released.
// Block 2: Access and Modify (Lock acquired by handle, then released)
{
// Acquire lock
auto handle = ptr_monitor_.handle();
handle->value = 42;
handle->name = "chained";
} // handle is destroyed, lock released.
// Block 3: Verify (Lock acquired, then released)
{
// Acquire lock
auto handle_check = ptr_monitor_.handle();
// Access via chaining
EXPECT_EQ(42, handle_check->value);
EXPECT_EQ("chained", handle_check->name);
}
}
TEST_F(MonitorTest, ThreadSafeIncrement) {
const int num_threads = 10;
const int increments_per_thread = 1000;
std::vector<std::thread> threads;
// Set initial value to 0
simple_monitor_.handle()->value = 0;
for (int i = 0; i < num_threads; ++i) {
threads.emplace_back([&] {
for (int j = 0; j < increments_per_thread; ++j) {
// Handle scope ensures RAII locking on every single increment
auto handle = simple_monitor_.handle();
handle->value++;
}
});
}
for (auto& t : threads) {
t.join();
}
// Verify the final value is correct
auto final_handle = simple_monitor_.handle();
EXPECT_EQ(num_threads * increments_per_thread, final_handle->value);
}
TEST_F(MonitorTest, ConditionVariableWaitNotify) {
bool done = false;
// Future/Promise pair 1: Waiter signals it is ready to wait
std::promise<void> waiter_ready_promise;
std::future<void> waiter_ready_future = waiter_ready_promise.get_future();
std::thread waiter([&] {
// Acquire handle
auto handle = simple_monitor_.handle();
// Signal that we are holding the lock and about to wait
waiter_ready_promise.set_value();
// Wait until 'done' is true.
handle.wait([&] { return done; });
EXPECT_EQ(99, handle->value);
});
// Main thread waits until the waiter has acquired the lock and set the promise
waiter_ready_future.get();
// Notifier thread operation (guaranteed to happen after waiter has locked/signaled)
{
// Acquire lock
auto handle = simple_monitor_.handle();
handle->value = 99;
done = true;
}
// Notify the waiting thread
simple_monitor_.notify_one();
waiter.join();
}
// --------------------------------------------------------------------------------
TEST_F(MonitorTest, TryLockHandleTimeout) {
// Future/Promise pair 1: Blocker signals it has acquired the lock
std::promise<void> blocker_locked_promise;
std::future<void> blocker_locked_future = blocker_locked_promise.get_future();
// The shared object is used as the resource for the lock
std::thread blocker([&] {
// Acquire the lock
auto handle = timed_mtx_monitor_.handle(); // 🔒 Lock acquired
// Signal to the main thread that the lock is held
blocker_locked_promise.set_value();
// Hold the lock for a specified duration
std::this_thread::sleep_for(BLOCK_TIME);
// Lock released when handle goes out of scope 🔓
});
// Main thread waits until the blocker explicitly confirms it is holding the lock
blocker_locked_future.get();
// Test 1: Try to acquire the lock with a short timeout (Expected to FAIL)
auto handle_opt = timed_mtx_monitor_.handle(SHORT_WAIT);
EXPECT_FALSE(handle_opt.has_value());
// Test 2: Try to acquire the lock with a long timeout (Expected to SUCCEED eventually)
// The total wait time will be slightly longer than BLOCK_TIME (200ms).
auto start_success = std::chrono::steady_clock::now();
auto handle_long_opt = timed_mtx_monitor_.handle(LONG_WAIT);
auto duration_success = std::chrono::steady_clock::now() - start_success;
EXPECT_TRUE(handle_long_opt.has_value());
// FIX 2: Explicitly compare the count() to ensure stable comparison and output
EXPECT_GE(duration_success.count(), BLOCK_TIME.count());
blocker.join();
}
TEST_F(MonitorTest, TimedMutexLockAcquisition) {
// This test ensures the complex timing logic for acquisition is sound.
// Synchronization barrier: Blocker signals it has acquired the lock
std::promise<void> blocker_locked_promise;
std::future<void> blocker_locked_future = blocker_locked_promise.get_future();
// THREAD A: The Blocker (Holds the lock on timed_mtx_monitor_)
std::thread blocker([&] {
// 1. Acquire lock
auto handle = timed_mtx_monitor_.handle();
blocker_locked_promise.set_value(); // Signal: Lock is now held
// 2. Hold the lock for the required duration
std::this_thread::sleep_for(BLOCK_TIME); // 200ms
// Lock released when handle goes out of scope
});
// 3. Main thread waits until the lock is actively held
blocker_locked_future.get();
// --- Test 1: Fail Case (Wait is shorter than remaining lock time) ---
auto fail_handle = timed_mtx_monitor_.handle(SHORT_WAIT);
EXPECT_FALSE(fail_handle.has_value());
// --- Test 2: Success Case (Wait is longer than remaining lock time) ---
auto start_success_timing = std::chrono::steady_clock::now();
// Acquire the lock with a sufficient timeout (300ms)
auto success_handle = timed_mtx_monitor_.handle(LONG_WAIT);
auto duration_success = std::chrono::steady_clock::now() - start_success_timing;
// Must succeed acquisition
EXPECT_TRUE(success_handle.has_value());
// FIX 3: Explicitly compare the count() to ensure stable comparison and output
EXPECT_GE(duration_success.count(), BLOCK_TIME.count());
blocker.join();
}
TEST_F(MonitorTest, ConditionVariableAtomicity) {
bool notification_sent = false;
// Use the SharedData member to track state
simple_monitor_.handle()->value = 0;
// THREAD A: The Waiter
std::thread waiter([&] {
auto handle = simple_monitor_.handle();
// Waiter signals that it is holding the lock and about to enter the wait state
// (This is implicitly tested by the notifier having to wait for the lock)
// Predicate check: Ensure 'notification_sent' is only true IF the lock is reacquired
handle.wait([&] {
// The predicate will be checked spuriously, but the critical check is on wake
return notification_sent;
});
// After waking up, the lock is held. Verify the resource state.
// This checks that the state modification (value=1) happened while the lock was released.
EXPECT_EQ(1, handle->value);
});
// Give the waiter time to acquire the lock and block on the CV (Crucial setup time)
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// THREAD B: The Notifier (This thread modifies the state and notifies)
{
// Must acquire the lock. This proves the waiter released it atomically inside wait().
auto handle = simple_monitor_.handle();
// 1. Modify the resource while holding the lock
handle->value = 1;
// 2. Set the wait condition *after* modification
notification_sent = true;
// Lock is released here, which allows the waiter to potentially reacquire it.
}
simple_monitor_.notify_one();
waiter.join();
// Final check that the state is 1, confirming the waiter successfully completed its EXPECT.
EXPECT_EQ(1, simple_monitor_.handle()->value);
}
TEST_F(MonitorTest, ThreadSafeMoveOperations) {
// Setup: Monitor m1 (Source) starts with data, Monitor m2 (Destination) starts empty.
monitor<SharedData> m1;
m1.handle()->value = 10;
auto m1_initial_id = m1.handle()->id; // Track resource identity
// Create m2 with different data
monitor<SharedData> m2;
m2.handle()->value = 99;
auto m2_initial_id = m2.handle()->id;
std::promise<void> blocker_locked_promise;
std::future<void> blocker_locked_future = blocker_locked_promise.get_future();
// THREAD A: The Blocker (Holds the lock on m1 to force the move operation to wait)
std::thread blocker([&] {
auto handle = m1.handle(); // Lock m1
blocker_locked_promise.set_value();
std::this_thread::sleep_for(BLOCK_TIME);
});
// Main thread waits until m1 is locked by the blocker
blocker_locked_future.get();
// --- Move Assignment Test: m2 = std::move(m1) ---
// Because the move assignment operator calls monitor::swap(m2, m1), and swap locks both,
// it must wait for m1's lock (held by blocker thread) to be released.
auto start_move = std::chrono::steady_clock::now();
m2 = std::move(m1); // Should block here until blocker releases m1's lock
auto duration_move = std::chrono::steady_clock::now() - start_move;
// Verify the move blocked until the blocker thread finished (duration > hold time)
EXPECT_GE(duration_move, BLOCK_TIME);
// Verify data transfer (m2 now has m1's initial data)
EXPECT_EQ(10, m2.handle()->value);
EXPECT_EQ(m1_initial_id, m2.handle()->id); // m2 now owns m1's resource
// Verify source state (m1 now has m2's initial data)
// The move assignment resulted in a SWAP.
EXPECT_EQ(99, m1.handle()->value);
EXPECT_EQ(m2_initial_id, m1.handle()->id); // m1 now owns m2's original resource
blocker.join();
}

View File

@@ -0,0 +1,537 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2026 Pionix GmbH and Contributors to EVerest
#include "gtest/gtest.h"
#include <atomic>
#include <chrono>
#include <everest/util/async/thread_pool_scaling.hpp>
#include <future>
#include <vector>
using namespace std::chrono_literals;
using namespace everest::lib::util;
// =================================================================
// 1. Latency Scaling Tests
// =================================================================
/**
* @test ScalesOnLatencyThreshold
* @brief Verifies that the pool spawns a new thread when a task waits too long.
*/
TEST(ThreadPoolScalingTest, ScalesOnLatencyThreshold) {
thread_pool_scaling<LatencyScaling<10>> pool(1, 2, 1s);
std::promise<void> block_first_task;
std::shared_future<void> block_future = block_first_task.get_future();
pool.run([block_future]() { block_future.wait(); });
std::this_thread::sleep_for(5ms);
std::atomic<bool> second_task_started{false};
// This should spawn a new thread already.
pool.run([&]() { second_task_started = true; });
ASSERT_FALSE(second_task_started.load());
std::this_thread::sleep_for(50ms);
EXPECT_TRUE(second_task_started.load());
block_first_task.set_value();
}
// =================================================================
// 2. Thread Retirement Tests
// =================================================================
/**
* @test SurplusThreadsRetireAfterTimeout
* @brief Verifies that threads exceeding the minimum count retire when idle.
*/
TEST(ThreadPoolScalingTest, SurplusThreadsRetireAfterTimeout) {
thread_pool_scaling<GreedyScaling> pool(1, 2, 100ms);
std::promise<void> p1, p2;
auto f1 = p1.get_future().share();
auto f2 = p2.get_future().share();
pool.run([f1]() { f1.wait(); });
pool.run([f2]() { f2.wait(); });
p1.set_value();
p2.set_value();
std::this_thread::sleep_for(300ms);
std::atomic<int> counter{0};
for (int i = 0; i < 5; ++i)
pool.run([&]() { counter++; });
std::this_thread::sleep_for(50ms);
EXPECT_EQ(counter.load(), 5);
}
// =================================================================
// 3. Backpressure Tests
// =================================================================
/**
* @test BackpressureBlocksProducer
* @brief Ensures the calling thread blocks when the queue limit is reached.
*/
TEST(ThreadPoolScalingTest, BackpressureBlocksProducer) {
thread_pool_scaling<GreedyScaling> pool(1, 1, 1s, 1);
std::promise<void> block;
auto fut = block.get_future().share();
pool.run([fut]() { fut.wait(); });
pool.run([]() {});
std::atomic<bool> producer_unblocked{false};
std::thread producer([&]() {
pool.run([]() {});
producer_unblocked = true;
});
std::this_thread::sleep_for(100ms);
EXPECT_FALSE(producer_unblocked.load());
block.set_value();
producer.join();
EXPECT_TRUE(producer_unblocked.load());
}
// =================================================================
// 4. Future Interface Tests
// =================================================================
/**
* @test OperatorReturnsValidFuture
*/
TEST(ThreadPoolScalingTest, OperatorReturnsValidFuture) {
thread_pool_scaling<GreedyScaling> pool(1, 2, 1s);
constexpr int lhs = 10;
constexpr int rhs = 32;
auto fut = pool([](int first, int second) { return first + second; }, lhs, rhs);
EXPECT_EQ(fut.get(), 42);
}
// =================================================================
// 5. Scaling and Retirement Stress Tests
// =================================================================
/**
* @test RapidScalingThrash
* @brief Verifies stability during high-frequency fluctuations in workload.
* @details Updated with more robust timing to handle OS scheduling jitter.
*/
TEST(ThreadPoolScalingStressTest, RapidScalingThrash) {
constexpr std::size_t max_threads = 20;
constexpr int burst_tasks = 40;
constexpr int trickle_tasks = 5;
constexpr int tasks_per_iteration = burst_tasks + trickle_tasks;
thread_pool_scaling<GreedyScaling> pool(1, max_threads, 20ms);
std::atomic<int> completed_tasks{0};
const int iterations = 30;
for (int i = 0; i < iterations; ++i) {
for (int j = 0; j < burst_tasks; ++j) {
pool.run([&]() {
std::this_thread::sleep_for(2ms);
completed_tasks++;
});
}
std::this_thread::sleep_for(30ms); // Allow some threads to start idling/retiring
for (int j = 0; j < trickle_tasks; ++j) {
pool.run([&]() { completed_tasks++; });
}
}
auto start = std::chrono::steady_clock::now();
while (completed_tasks < (iterations * tasks_per_iteration)) {
std::this_thread::sleep_for(50ms);
if (std::chrono::steady_clock::now() - start > 10s) {
break;
}
}
EXPECT_EQ(completed_tasks.load(), iterations * tasks_per_iteration);
}
// =================================================================
// 6. High Contention and Race Condition Tests
// =================================================================
/**
* @test HighContentionProducers
*/
TEST(ThreadPoolScalingStressTest, HighContentionProducers) {
constexpr int num_producers = 8;
constexpr int tasks_per_producer = 2000;
constexpr std::size_t max_threads = 16;
thread_pool_scaling<LatencyScaling<5>> pool(4, max_threads, 1s);
std::atomic<size_t> total_sum{0};
std::vector<std::thread> producers;
producers.reserve(static_cast<std::size_t>(num_producers));
for (int prod = 0; prod < num_producers; ++prod) {
producers.emplace_back([&]() {
for (int i = 0; i < tasks_per_producer; ++i) {
pool.run([&total_sum]() { total_sum.fetch_add(1, std::memory_order_relaxed); });
}
});
}
for (auto& thr : producers) {
thr.join();
}
const auto expected = static_cast<std::size_t>(num_producers) * static_cast<std::size_t>(tasks_per_producer);
auto start = std::chrono::steady_clock::now();
while (total_sum.load() < expected) {
std::this_thread::sleep_for(50ms);
if (std::chrono::steady_clock::now() - start > 10s) {
break;
}
}
EXPECT_EQ(total_sum.load(), expected);
}
// =================================================================
// 7. Thread retirement versus pool destruction
// =================================================================
/**
* @test DestructorVsActiveScalingRace
*/
TEST(ThreadPoolScalingStressTest, DestructorVsActiveScalingRace) {
constexpr int repetitions = 50;
constexpr std::size_t max_threads = 10;
constexpr int tasks_per_rep = 20;
for (int i = 0; i < repetitions; ++i) {
{
thread_pool_scaling<GreedyScaling> pool(1, max_threads, 5ms);
for (int j = 0; j < tasks_per_rep; ++j) {
pool.run([]() { std::this_thread::sleep_for(1ms); });
}
std::this_thread::sleep_for(6ms);
}
}
}
// =================================================================
// 8. Edge Case: Full Idle Reset
// =================================================================
/**
* @test FullIdleResetToMinimum
*/
TEST(ThreadPoolScalingTest, FullIdleResetToMinimum) {
const size_t min = 2;
const size_t max = 5;
const auto timeout = 50ms;
thread_pool_scaling<GreedyScaling> pool(min, max, timeout);
std::vector<std::promise<void>> promises(max);
for (int i = 0; i < max; ++i) {
pool.run([&promises, i]() { promises[i].get_future().wait(); });
}
for (auto& prom : promises) {
prom.set_value();
}
std::this_thread::sleep_for(timeout * 3);
std::atomic<bool> functional_check{false};
pool.run([&]() { functional_check = true; });
auto start = std::chrono::steady_clock::now();
while (!functional_check.load() && std::chrono::steady_clock::now() - start < 1s) {
std::this_thread::yield();
}
ASSERT_TRUE(functional_check.load());
}
// =================================================================
// 9. Re-entrancy and Policy Boundary Tests
// =================================================================
/**
* @test ReentrantScaling
*/
TEST(ThreadPoolScalingStressTest, ReentrantScaling) {
constexpr int inner_tasks = 10;
constexpr int total_tasks = inner_tasks + 1; // outer task + inner tasks
thread_pool_scaling<LatencyScaling<10>> pool(1, 4, 1s);
std::atomic<int> completed{0};
pool.run([&]() {
for (int i = 0; i < inner_tasks; ++i) {
pool.run([&]() { completed++; });
}
completed++;
});
auto start = std::chrono::steady_clock::now();
while (completed < total_tasks && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_EQ(completed.load(), total_tasks);
}
// =================================================================
// 10. Latency Boundary Check
// =================================================================
/**
* @test LatencyThresholdBoundary
*/
TEST(ThreadPoolScalingTest, LatencyThresholdBoundary) {
// 100 ms threshold: tasks that wait less than 100 ms should NOT trigger scaling
constexpr std::size_t threshold_ms = 100;
thread_pool_scaling<LatencyScaling<threshold_ms>> pool(1, 2, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
pool.run([fut]() { fut.wait(); });
std::atomic<bool> task2_started{false};
pool.run([&]() { task2_started = true; });
std::this_thread::sleep_for(50ms);
EXPECT_FALSE(task2_started.load());
std::this_thread::sleep_for(100ms);
auto start = std::chrono::steady_clock::now();
while (!task2_started.load() && std::chrono::steady_clock::now() - start < 1s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_TRUE(task2_started.load());
block.set_value();
}
// =================================================================
// 11. ConservativeScaling Policy Tests
// =================================================================
/**
* @test ConservativeScalingDoesNotGrowBelowThreshold
* @brief With 1 worker, queue_size must exceed workers*2 (>2) to trigger growth.
* At exactly 2 queued tasks the policy should NOT scale.
*/
TEST(ThreadPoolScalingTest, ConservativeScalingDoesNotGrowBelowThreshold) {
// min=1, max=2 — second thread must NOT appear unless queue_size > 2
thread_pool_scaling<ConservativeScaling> pool(1, 2, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
// Task 1: occupies the sole min thread
pool.run([fut]() { fut.wait(); });
// Task 2: queue_size after push == 2, workers == 1, 2 > (1*2) is false → no growth
std::atomic<bool> task2_ran{false};
pool.run([&]() { task2_ran = true; });
std::this_thread::sleep_for(100ms);
EXPECT_FALSE(task2_ran.load()); // still blocked behind task 1
block.set_value();
auto start = std::chrono::steady_clock::now();
while (!task2_ran.load() && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_TRUE(task2_ran.load());
}
/**
* @test ConservativeScalingGrowsAboveThreshold
* @brief With 1 worker, submitting 3 tasks (queue_size==3 > workers*2==2) must trigger growth.
*/
TEST(ThreadPoolScalingTest, ConservativeScalingGrowsAboveThreshold) {
thread_pool_scaling<ConservativeScaling> pool(1, 3, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
// Task 1: pins the min thread
pool.run([fut]() { fut.wait(); });
// Tasks 2 and 3: queue_size after task 3 == 3, workers == 1, 3 > 2 → growth
std::atomic<int> ran{0};
pool.run([&]() { ran++; });
pool.run([&]() { ran++; });
block.set_value();
auto start = std::chrono::steady_clock::now();
while (ran.load() < 2 && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_EQ(ran.load(), 2);
}
// =================================================================
// 12. FixedSizeScaling Policy Tests
// =================================================================
/**
* @test FixedSizeScalingDoesNotGrowBeforeLimit
* @brief Pool must not scale when queue_size is below the fixed limit.
*/
TEST(ThreadPoolScalingTest, FixedSizeScalingDoesNotGrowBeforeLimit) {
// Limit=3: grows only when queue_size >= 3
thread_pool_scaling<FixedSizeScaling<3>> pool(1, 2, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
pool.run([fut]() { fut.wait(); });
// queue_size == 2 after this push, 2 < 3 → no growth
std::atomic<bool> task2_ran{false};
pool.run([&]() { task2_ran = true; });
std::this_thread::sleep_for(100ms);
EXPECT_FALSE(task2_ran.load());
block.set_value();
auto start = std::chrono::steady_clock::now();
while (!task2_ran.load() && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_TRUE(task2_ran.load());
}
/**
* @test FixedSizeScalingGrowsAtLimit
* @brief Pool must spawn a new thread exactly when queue_size reaches the fixed limit.
*/
TEST(ThreadPoolScalingTest, FixedSizeScalingGrowsAtLimit) {
// Limit=2: grows when queue_size >= 2
thread_pool_scaling<FixedSizeScaling<2>> pool(1, 2, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
pool.run([fut]() { fut.wait(); });
// queue_size == 2 after this push, 2 >= 2 → growth
std::atomic<bool> task2_ran{false};
pool.run([&]() { task2_ran = true; });
auto start = std::chrono::steady_clock::now();
while (!task2_ran.load() && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_TRUE(task2_ran.load());
block.set_value();
}
// =================================================================
// 13. min == max Degenerate Case
// =================================================================
/**
* @test FixedSizePoolNeverGrows
* @brief When min == max the pool must never spawn additional threads regardless of backlog.
*/
TEST(ThreadPoolScalingTest, FixedSizePoolNeverGrows) {
// min == max == 1: only ever one worker
thread_pool_scaling<GreedyScaling> pool(1, 1, 1s);
std::promise<void> block;
auto fut = block.get_future().share();
pool.run([fut]() { fut.wait(); });
// Queue up several tasks; none can run until the first finishes
std::atomic<int> ran{0};
for (int i = 0; i < 4; ++i) {
pool.run([&]() { ran++; });
}
std::this_thread::sleep_for(100ms);
EXPECT_EQ(ran.load(), 0); // no second thread spawned → all queued behind task 1
block.set_value();
auto start = std::chrono::steady_clock::now();
while (ran.load() < 4 && std::chrono::steady_clock::now() - start < 2s) {
std::this_thread::sleep_for(10ms);
}
EXPECT_EQ(ran.load(), 4); // all tasks complete once the single worker is unblocked
}
// =================================================================
// 14. Zombie reaping correctness
// =================================================================
/**
* @test ZombiesAreJoinedAfterRetirement
* @brief Surplus threads that voluntarily retire must be fully joined — verified by
* the pool destructor completing without hanging or calling std::terminate().
*/
TEST(ThreadPoolScalingTest, ZombiesAreJoinedAfterRetirement) {
// Short idle timeout so surplus threads retire quickly
thread_pool_scaling<GreedyScaling> pool(1, 4, 20ms);
std::promise<void> gate;
auto fut = gate.get_future().share();
// Flood the pool to force scale-up to max
for (int i = 0; i < 4; ++i) {
pool.run([fut]() { fut.wait(); });
}
gate.set_value();
// Let all surplus threads go idle and retire into the zombie deque
std::this_thread::sleep_for(200ms);
// Destructor must complete cleanly: all zombies are joined before destruction
}
/**
* @test ZombiesReapedConcurrentlyWithTaskExecution
* @brief Zombies created during task execution must be reaped correctly
* by the worker loop while other tasks continue to execute.
*/
TEST(ThreadPoolScalingStressTest, ZombiesReapedConcurrentlyWithTaskExecution) {
constexpr std::size_t max_threads = 8;
constexpr int num_waves = 5;
constexpr int tasks_per_wave = 10;
constexpr int total_tasks = num_waves * tasks_per_wave;
// Short timeout forces retirement while tasks keep arriving
thread_pool_scaling<GreedyScaling> pool(1, max_threads, 10ms);
std::atomic<int> completed{0};
// Submit waves of tasks separated by the idle timeout to repeatedly
// grow-then-shrink the pool, generating zombies during active execution
for (int wave = 0; wave < num_waves; ++wave) {
for (int i = 0; i < tasks_per_wave; ++i) {
pool.run([&]() {
std::this_thread::sleep_for(5ms);
completed++;
});
}
std::this_thread::sleep_for(15ms); // retire surplus threads between waves
}
auto start = std::chrono::steady_clock::now();
while (completed.load() < total_tasks && std::chrono::steady_clock::now() - start < 5s) {
std::this_thread::sleep_for(20ms);
}
EXPECT_EQ(completed.load(), total_tasks);
// Destructor must complete cleanly with no unjoined zombie threads
}

View File

@@ -0,0 +1,265 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include "gtest/gtest.h"
#include <atomic>
#include <chrono>
#include <everest/util/async/thread_pool.hpp>
#include <iostream>
#include <numeric>
#include <stdexcept>
#include <thread>
#include <vector>
using namespace everest::lib::util;
// --- Test Fixture ---
// A fixture allows us to set up and tear down the thread pool easily.
class ThreadPoolTest : public ::testing::Test {
protected:
// Pool size chosen small to encourage contention
const unsigned int POOL_SIZE = 4;
// The thread_pool object will be initialized here and automatically
// destroyed (and joined) when the test ends.
thread_pool* pool;
void SetUp() override {
pool = new thread_pool(POOL_SIZE);
}
void TearDown() override {
delete pool;
pool = nullptr;
}
};
// --- Helper Functions for Binding ---
// Example function to test argument passing
int add(int a, int b) {
return a + b;
}
// Example function to test void return
void do_nothing() {
// This is run by a worker thread
}
// --- Test Cases ---
// 1. Basic Correctness Tests
// -------------------------
TEST_F(ThreadPoolTest, Test_Immediate_Execution_And_Contention) {
std::atomic<int> counter{0};
const int num_tasks = 1000;
std::vector<std::future<void>> futures;
// Submit many more tasks than threads to test queue contention
for (int i = 0; i < num_tasks; ++i) {
// Use a lambda with no arguments (uses the specialized operator() if available)
futures.push_back((*pool)([&counter]() { counter++; }));
}
// Wait for all tasks to complete
for (auto& f : futures) {
f.get();
}
// Check if all tasks ran correctly
ASSERT_EQ(counter.load(), num_tasks);
}
TEST_F(ThreadPoolTest, Test_Future_Return_Value_And_Arguments) {
// Test passing arguments to a simple function
std::future<int> f1 = (*pool)(add, 10, 20);
// Test a lambda with a return value and local arguments
int multiplier = 5;
std::future<double> f2 = (*pool)([multiplier](double val) { return val * multiplier; }, 10.0);
ASSERT_EQ(f1.get(), 30);
ASSERT_DOUBLE_EQ(f2.get(), 50.0);
}
TEST_F(ThreadPoolTest, Test_Run_Is_NonBlocking) {
auto start = std::chrono::steady_clock::now();
// Submit a task that takes 500ms
pool->run([]() { std::this_thread::sleep_for(std::chrono::milliseconds(500)); });
auto end = std::chrono::steady_clock::now();
auto elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(end - start);
// It should return in basically 0ms (well under 50ms)
EXPECT_LT(elapsed.count(), 50) << "run() blocked the caller!";
}
TEST_F(ThreadPoolTest, Test_Recursive_Submission) {
std::atomic<int> result{0};
std::future<void> f = (*pool)([this, &result]() {
// Task A submits Task B
std::future<int> f2 = (*pool)([]() { return 42; });
result = f2.get();
});
f.get();
ASSERT_EQ(result.load(), 42);
}
TEST_F(ThreadPoolTest, Test_Multi_Producer_Contention) {
std::atomic<int> counter{0};
const int num_producers = 4;
const int tasks_per_producer = 250;
std::vector<std::thread> producers;
for (int i = 0; i < num_producers; ++i) {
producers.emplace_back([this, &counter, tasks_per_producer]() {
for (int j = 0; j < tasks_per_producer; ++j) {
pool->run([&counter]() { counter++; });
}
});
}
for (auto& t : producers)
t.join();
// Give workers a moment to finish the queue
std::this_thread::sleep_for(std::chrono::milliseconds(100));
ASSERT_EQ(counter.load(), num_producers * tasks_per_producer);
}
// 2. Exception and Error Handling Tests
// ------------------------------------
TEST_F(ThreadPoolTest, Test_Task_Exception_Transfer) {
// Submit a task that throws an exception
auto throwing_task = []() -> int {
throw std::runtime_error("Task failed intentionally");
return 42;
};
std::future<int> f = (*pool)(throwing_task);
// future::get() should re-throw the exception from the worker thread
ASSERT_THROW(f.get(), std::runtime_error);
}
// 3. Critical Shutdown Tests
// -------------------------
TEST_F(ThreadPoolTest, Test_Clean_Destruction_Blocked_Workers) {
const int num_threads = POOL_SIZE;
std::vector<std::future<void>> futures;
std::atomic<int> started_count{0};
// Submit exactly POOL_SIZE tasks that sleep for a long time
for (int i = 0; i < num_threads; ++i) {
futures.push_back((*pool)([&started_count]() {
started_count++;
// Block the thread, forcing the destructor to wait
std::this_thread::sleep_for(std::chrono::milliseconds(200));
}));
}
// Wait briefly to ensure all threads have started their tasks
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// The destruction of the 'pool' fixture happens automatically in TearDown.
// TearDown calls 'delete pool', which calls '~thread_pool()'.
// If TearDown completes without crashing, the test passes.
ASSERT_EQ(started_count.load(), num_threads) << "Not all threads started blocking tasks.";
}
TEST_F(ThreadPoolTest, Test_Clean_Destruction_Full_Queue) {
const int num_tasks_to_queue = POOL_SIZE * 5; // Overwhelm the pool
std::vector<std::future<void>> futures;
// Submit many tasks, including long-running ones
for (int i = 0; i < num_tasks_to_queue; ++i) {
futures.push_back((*pool)([]() {
// Mix of fast and slow tasks
if (rand() % 10 == 0) {
std::this_thread::sleep_for(std::chrono::milliseconds(10));
}
}));
}
// Do NOT call f.get() here. Let the pool be destroyed immediately,
// simulating a sudden program exit.
// The destruction in TearDown must complete without a deadlock.
SUCCEED(); // If TearDown completes, the shutdown was clean.
}
TEST_F(ThreadPoolTest, Test_Void_Return) {
std::future<void> f = (*pool)(do_nothing);
// future::get() must be called to ensure the task finished without exception
// It returns void, but checks for exceptions set on the promise.
f.get();
SUCCEED();
}
TEST_F(ThreadPoolTest, Test_Parallel_Execution_Proved) {
// A single task duration that is long enough to measure accurately.
const auto task_duration = std::chrono::milliseconds(100);
// Number of tasks equals the number of threads in the pool
const unsigned int num_tasks = POOL_SIZE;
// Calculate the expected sequential time (N tasks * T duration)
const auto expected_sequential_duration = task_duration * num_tasks;
// The expected parallel time should be slightly more than one task's duration
// We use a large tolerance factor (e.g., 2.5x the single task duration)
// to account for thread creation, scheduling, and I/O overhead.
const auto expected_parallel_limit = task_duration * 2.5;
std::vector<std::future<void>> futures;
auto start_time = std::chrono::steady_clock::now();
// 1. Submit N blocking tasks (one for each thread)
for (unsigned int i = 0; i < num_tasks; ++i) {
futures.push_back((*pool)([task_duration]() { std::this_thread::sleep_for(task_duration); }));
}
// 2. Wait for all tasks to complete
for (auto& f : futures) {
f.get();
}
auto end_time = std::chrono::steady_clock::now();
auto actual_duration = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time);
// 3. Assert parallelism
// Log results for manual inspection if the test fails
// std::cout << "\n[ PARALLELISM TEST ]" << std::endl;
// std::cout << " Pool Size: " << POOL_SIZE << " threads" << std::endl;
// std::cout << " Single Task Time: " << task_duration.count() << "ms" << std::endl;
// std::cout << " Sequential Expected: " << expected_sequential_duration.count() << "ms" << std::endl;
// std::cout << " Parallel Limit: " << expected_parallel_limit.count() << "ms" << std::endl;
// std::cout << " Actual Time Taken: " << actual_duration.count() << "ms" << std::endl;
// CRITICAL ASSERTION: The actual time must be much less than the sequential time.
// Use EXPECT_LT (less than) against the safe parallel limit.
EXPECT_LT(actual_duration.count(), expected_parallel_limit.count())
<< "The total time taken (" << actual_duration.count() << "ms) suggests tasks ran sequentially."
<< "Expected time less than " << expected_parallel_limit.count() << "ms for parallel execution.";
}
TEST(ThreadPoolStress, Test_Rapid_Lifecycle) {
for (int i = 0; i < 50; ++i) {
thread_pool temporary_pool(2);
for (int j = 0; j < 10; ++j) {
temporary_pool.run([]() { std::this_thread::yield(); });
}
// Destruction happens immediately
}
SUCCEED();
}

View File

@@ -0,0 +1,288 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2020 - 2025 Pionix GmbH and Contributors to EVerest
#include "gtest/gtest.h"
#include <everest/util/async/async_wrapper.hpp>
#include <exception>
#include <future>
#include <memory>
#include <stdexcept>
#include <string>
#include <thread>
#include <vector>
struct Counter {
Counter(int v) : value(v) {
}
int value = 0;
std::thread::id worker_id;
// Mutator
void add(int v) {
value += v;
worker_id = std::this_thread::get_id();
}
// Accessor
int get() const {
return value;
}
// Thrower
int throw_if_equal(int v) {
if (value == v) {
throw std::runtime_error("User-defined fatal error.");
}
return value;
}
};
using namespace everest::lib::util;
// --- GTEST FIXTURE ---
namespace everest::lib::util::testing_interface {
class AsyncWrapperTest : public ::testing::Test {
public:
template <class T> std::shared_ptr<std::promise<void>> const& get_global_promise_for_test(T& wrapper) const {
return wrapper.m_global_promise;
}
template <class T> auto& get_queue_for_test(T& wrapper) {
return wrapper.m_queue;
}
};
} // namespace everest::lib::util::testing_interface
using namespace everest::lib::util::testing_interface;
template <class T> class TestQueue : public thread_safe_queue<T> {
public:
using ThisT = thread_safe_queue<T>;
void push(T item) {
ThisT::push(item);
}
T pop() {
if (m_throw_on_next_pop) {
throw std::runtime_error("oh no");
}
auto tmp = ThisT::pop();
return tmp;
}
void force_throw_on_next_pop() {
m_throw_on_next_pop = true;
push([]() {});
}
private:
bool m_throw_on_next_pop{false};
};
template <typename T>
using async_guarded_testqueue = async_wrapper_impl<T, GlobalFailurePolicy, WaitToFinishPolicy, TestQueue>;
// Test 1: Basic functionality and thread serialization (Background/WaitToFinish)
TEST_F(AsyncWrapperTest, CoreFunctionality) {
async_wrapper_wait<Counter> wrapper(0);
// 1. Check asynchronous execution and result retrieval
auto fut1 = wrapper([](Counter& c) {
c.add(5);
return c.get();
});
auto fut2 = wrapper([](Counter& c) {
c.add(10);
return c.get();
});
EXPECT_EQ(fut1.get(), 5);
EXPECT_EQ(fut2.get(), 15);
// 2. Check side effect on resource
auto fut3 = wrapper([](Counter& c) { return c.get(); });
EXPECT_EQ(fut3.get(), 15);
// 3. Check 'run' (fire-and-forget)
wrapper.run([](Counter& c) { c.add(5); });
auto fut4 = wrapper([](Counter& c) { return c.get(); });
EXPECT_EQ(fut4.get(), 20);
// 4. Check thread ID
std::thread::id main_thread_id = std::this_thread::get_id();
auto fut_id = wrapper([](Counter& c) { return c.worker_id; });
EXPECT_NE(fut_id.get(), main_thread_id);
}
// Test 2: LocalFailurePolicy (Background) - User Exception is Isolated
TEST_F(AsyncWrapperTest, LocalPolicy_UserExceptionIsContained) {
async_wrapper_wait<Counter> wrapper(5);
auto fut_a = wrapper([](Counter& c) { return c.throw_if_equal(5); });
auto fut_b = wrapper([](Counter& c) {
c.add(10);
return c.get();
});
fut_a.wait();
fut_b.wait();
ASSERT_THROW(fut_a.get(), std::runtime_error);
int received_value = 0;
EXPECT_NO_THROW(received_value = fut_b.get());
EXPECT_EQ(received_value, 15);
auto fut_c = wrapper([](Counter& c) { return c.get(); });
fut_c.wait();
EXPECT_EQ(fut_c.get(), 15);
}
// Test 3: GlobalFailurePolicy (Guarded) - User Exception Shuts Down Instance
TEST_F(AsyncWrapperTest, GlobalPolicy_UserExceptionCausesShutdown) {
async_wrapper_guarded_wait<Counter> wrapper(5);
auto fut_a = wrapper([](Counter& c) { return c.throw_if_equal(5); });
auto fut_b = wrapper([](Counter& c) {
c.add(10);
return c.get();
});
fut_a.wait();
fut_b.wait();
ASSERT_THROW(fut_a.get(), std::runtime_error);
ASSERT_THROW(fut_b.get(), std::runtime_error);
auto fut_c = wrapper([](Counter& c) { return c.get(); });
fut_c.wait();
ASSERT_THROW(fut_c.get(), std::runtime_error);
}
// Test 4: GlobalFailureSignal_BlocksNewTasks (Tests signal effect)
TEST_F(AsyncWrapperTest, GlobalFailureSignal_BlocksNewTasks) {
async_wrapper_guarded_wait<Counter> wrapper(0);
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// 1. Manually set the Global Promise (simulating infrastructure or user failure)
try {
throw std::runtime_error("Simulated Global Signal Set.");
} catch (...) {
get_global_promise_for_test(wrapper)->set_exception(std::current_exception());
}
// 2. Submit a new task (Task B)
auto fut_b = wrapper([](Counter& c) {
c.add(50);
return c.get();
});
fut_b.wait();
// 3. Task B must immediately fail because the infrastructure flag is set
ASSERT_THROW(fut_b.get(), std::runtime_error);
}
// Test 5: Destructor Behavior - WaitToFinishPolicy vs FastQuitPolicy
TEST_F(AsyncWrapperTest, DestructorShutdownPolicies) {
// Setup 1: Test WaitToFinishPolicy (Guaranteed execution of queued task)
int wait_result = 0;
{
async_wrapper_guarded_wait<Counter> wrapper(0);
auto fut = wrapper([&wait_result](Counter& c) {
std::this_thread::sleep_for(std::chrono::milliseconds(50));
wait_result = 1;
return c.get();
});
// Destructor runs here (WaitToFinishPolicy::shutdown), MUST wait 50ms.
}
// Result confirms the destructor waited for the task to finish.
EXPECT_EQ(wait_result, 1);
// Setup 2: Test FastQuitPolicy (Drops queued task, joins quickly)
int fast_result = 0;
{
async_wrapper_guarded_fast<Counter> wrapper(0);
// Push a task that runs briefly. If the task starts, the destructor must wait.
// We rely on the race condition being won by the destructor for EXPECT_EQ(0) to pass.
wrapper.run([&fast_result](Counter& c) {
std::this_thread::sleep_for(std::chrono::microseconds(100)); // Very fast sleep
fast_result = 2; // Should not reach here if the task is aborted while queued
});
// Destructor runs here (FastQuitPolicy::shutdown), should join quickly.
}
// If fast_result == 0, the task was aborted while queued.
// If fast_result == 2, the task started and the destructor waited for it to finish.
EXPECT_EQ(fast_result, 0);
}
// Test 6: Verify Worker's internal catch block works correctly
TEST_F(AsyncWrapperTest, InfrastructureFailure_WorkerSetsSignalAndShutsDown) {
async_guarded_testqueue<Counter> wrapper(0); // Use the specialized TestQueue type
// 1. Ensure worker thread is blocked on pop()
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// 2. Force the queue to throw an exception, waking up the worker thread
get_queue_for_test(wrapper).force_throw_on_next_pop();
// 3. Give the worker time to execute the catch block and shut down.
std::this_thread::sleep_for(std::chrono::milliseconds(50));
// 4. Submit a new task (Task A). This hits the Synchronous Gatekeeper check.
auto fut_a = wrapper([](Counter& c) {
c.add(99);
return c.get();
});
fut_a.wait();
// 5. Task A must fail with the infrastructure exception
bool active_runtime_error = false;
std::string message = "";
try {
fut_a.get();
} catch (const std::runtime_error& e) {
message = e.what();
active_runtime_error = true;
}
EXPECT_TRUE(active_runtime_error);
EXPECT_EQ(message, "Async worker infrastructure failure.");
}
// Test 7: Run method must not block the main thread (optimized fire-and-forget)
TEST_F(AsyncWrapperTest, RunMethodDoesNotBlock) {
// We use the WaitToFinish policy so we know the task will complete before the test ends.
async_wrapper_wait<Counter> wrapper(0);
const int SLOW_TASK_MS = 50;
auto slow_task = [SLOW_TASK_MS](Counter& c) {
// Task takes significant time to execute
std::this_thread::sleep_for(std::chrono::milliseconds(SLOW_TASK_MS));
c.add(1);
};
auto start_time = std::chrono::steady_clock::now();
// Submit the slow task using the optimized run() method
wrapper.run(slow_task);
auto end_time = std::chrono::steady_clock::now();
// The main thread should not have blocked for the duration of the task.
// The elapsed time should be much less than the task execution time (10ms tolerance).
auto elapsed_ms = std::chrono::duration_cast<std::chrono::milliseconds>(end_time - start_time).count();
// 1. Assertion on timing: Proves non-blocking submission
EXPECT_LT(elapsed_ms, 10);
// 2. Assert task eventually ran (rely on WaitToFinishPolicy destructor)
auto fut_check = wrapper([](Counter& c) { return c.get(); });
EXPECT_EQ(fut_check.get(), 1);
}