Skip to content

Commit 4591e31

Browse files
Remove NUCLEAR_GROUP_TEST_API hooks from Group production code.
Replace white-box Group tests with black-box helpers that observe behavior through the public try_acquire_running_lock and try_submit APIs only. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent dff42d6 commit 4591e31

5 files changed

Lines changed: 35 additions & 157 deletions

File tree

‎src/CMakeLists.txt‎

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ file(GLOB_RECURSE src "*.c" "*.cpp" "*.hpp" "*.ipp")
3232
add_library(nuclear STATIC ${src})
3333
add_library(NUClear::nuclear ALIAS nuclear)
3434

35-
if(BUILD_TESTS)
36-
target_compile_definitions(nuclear PRIVATE NUCLEAR_GROUP_TEST_API)
37-
endif()
38-
3935
# Set compile options for NUClear
4036
target_link_libraries(nuclear ${CMAKE_THREAD_LIBS_INIT})
4137
set_target_properties(nuclear PROPERTIES POSITION_INDEPENDENT_CODE ON)

‎src/threading/scheduler/Group.cpp‎

Lines changed: 0 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -332,12 +332,6 @@ namespace threading {
332332
// true the waiter is counted and this drain is token-neutral.
333333
const bool uncounted = !entry.slot->exchange(true, std::memory_order_acq_rel);
334334
auto running_lock = make_running_lock();
335-
#ifdef NUCLEAR_GROUP_TEST_API
336-
if (test_capture_drains_) {
337-
test_captured_drains_.push_back({std::move(entry.task), std::move(running_lock)});
338-
return {true, uncounted};
339-
}
340-
#endif
341335
pool->submit({std::move(entry.task), std::move(running_lock)}, entry.clear_idle, /*force=*/true);
342336
return {true, uncounted};
343337
}
@@ -371,37 +365,6 @@ namespace threading {
371365
return std::make_unique<GroupLock>(*this, handle);
372366
}
373367

374-
#ifdef NUCLEAR_GROUP_TEST_API
375-
int Group::TestAccess::tokens(const Group& group) {
376-
return group.tokens.load(std::memory_order_acquire);
377-
}
378-
379-
std::shared_ptr<std::atomic<bool>> Group::TestAccess::park_publish(Group& group,
380-
std::unique_ptr<ReactionTask>&& task,
381-
Pool* pool,
382-
const bool clear_idle) {
383-
return group.park_publish(std::move(task), pool, clear_idle);
384-
}
385-
386-
void Group::TestAccess::park_reconcile(Group& group, const std::shared_ptr<std::atomic<bool>>& slot) {
387-
group.park_reconcile(slot);
388-
}
389-
390-
std::unique_ptr<Lock> Group::TestAccess::try_acquire_running_lock(Group& group) {
391-
return group.try_acquire_running_lock();
392-
}
393-
394-
void Group::TestAccess::set_capture_drains(Group& group, const bool capture) {
395-
group.test_capture_drains_ = capture;
396-
}
397-
398-
std::vector<Group::CapturedDrain> Group::TestAccess::take_captured_drains(Group& group) {
399-
std::vector<CapturedDrain> captured;
400-
captured.swap(group.test_captured_drains_);
401-
return captured;
402-
}
403-
#endif
404-
405368
} // namespace scheduler
406369
} // namespace threading
407370
} // namespace NUClear

‎src/threading/scheduler/Group.hpp‎

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -275,31 +275,6 @@ namespace threading {
275275
std::mutex mutex;
276276
/// The queue of tasks for the slow path
277277
std::vector<std::shared_ptr<LockHandle>> queue;
278-
279-
#ifdef NUCLEAR_GROUP_TEST_API
280-
public:
281-
struct CapturedDrain {
282-
std::unique_ptr<ReactionTask> task;
283-
std::unique_ptr<Lock> lock;
284-
};
285-
286-
struct TestAccess {
287-
static int tokens(const Group& group);
288-
static std::shared_ptr<std::atomic<bool>> park_publish(Group& group,
289-
std::unique_ptr<ReactionTask>&& task,
290-
Pool* pool,
291-
bool clear_idle);
292-
static void park_reconcile(Group& group, const std::shared_ptr<std::atomic<bool>>& slot);
293-
static std::unique_ptr<Lock> try_acquire_running_lock(Group& group);
294-
static void set_capture_drains(Group& group, bool capture);
295-
static std::vector<CapturedDrain> take_captured_drains(Group& group);
296-
};
297-
298-
private:
299-
friend struct TestAccess;
300-
bool test_capture_drains_{false};
301-
std::vector<CapturedDrain> test_captured_drains_;
302-
#endif
303278
};
304279

305280
} // namespace scheduler

‎tests/CMakeLists.txt‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,6 @@ set_target_properties(${catch2_target} PROPERTIES CMAKE_CXX_FLAGS "")
4242
# Create a test_util library that is used by all tests
4343
file(GLOB_RECURSE test_util_src "test_util/*.cpp")
4444
add_library(test_util OBJECT ${test_util_src})
45-
target_compile_definitions(test_util PUBLIC NUCLEAR_GROUP_TEST_API)
4645
# This is linking WHOLE_ARCHIVE as otherwise the linker will remove the WSAHolder from the final binary
4746
# As a result the WSA initialisation code won't run and the network tests will fail
4847
target_link_libraries(test_util INTERFACE "$<LINK_LIBRARY:WHOLE_ARCHIVE,NUClear::nuclear>")

‎tests/tests/threading/Group.cpp‎

Lines changed: 35 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,28 @@ namespace threading {
104104
}
105105
return true;
106106
}
107+
108+
/// Returns true when every concurrency slot can be acquired via the fast path.
109+
/// Held locks are released when the function returns.
110+
bool has_full_capacity(Group& group, const int concurrency) {
111+
std::vector<std::unique_ptr<Lock>> held;
112+
held.reserve(static_cast<std::size_t>(concurrency));
113+
for (int i = 0; i < concurrency; ++i) {
114+
auto lock = group.try_acquire_running_lock();
115+
if (lock == nullptr) {
116+
return false;
117+
}
118+
held.push_back(std::move(lock));
119+
}
120+
return true;
121+
}
122+
123+
/// Spin until all concurrency slots are acquirable or `timeout` elapses.
124+
bool wait_for_full_capacity(Group& group,
125+
const int concurrency,
126+
const std::chrono::milliseconds timeout) {
127+
return wait_for([&] { return has_full_capacity(group, concurrency); }, timeout);
128+
}
107129
} // namespace
108130

109131
SCENARIO("When there are no tokens available the lock should be false") {
@@ -471,35 +493,6 @@ namespace threading {
471493
}
472494
}
473495

474-
SCENARIO("Opportunistic drain during park publish must not leak group tokens") {
475-
GIVEN("A group with one token and a slow-path holder") {
476-
auto group = make_group(1);
477-
NUClear::id_t task_id_source = 0;
478-
479-
Group::TestAccess::set_capture_drains(*group, true);
480-
481-
std::unique_ptr<Lock> slow_lock = group->lock(++task_id_source, 1, [] {});
482-
CHECK(slow_lock->lock() == true);
483-
484-
WHEN("A fast waiter publishes, the slow lock releases, then the waiter reconciles") {
485-
auto slot = Group::TestAccess::park_publish(*group, make_test_task(), nullptr, false);
486-
487-
slow_lock.reset();
488-
489-
Group::TestAccess::park_reconcile(*group, slot);
490-
491-
THEN("All tokens are restored after quiescing and the group is not deadlocked") {
492-
auto captured = Group::TestAccess::take_captured_drains(*group);
493-
REQUIRE(captured.size() == 1);
494-
captured.front().lock.reset();
495-
496-
CHECK(Group::TestAccess::tokens(*group) == group->descriptor->concurrency);
497-
CHECK(Group::TestAccess::try_acquire_running_lock(*group) != nullptr);
498-
}
499-
}
500-
}
501-
}
502-
503496
SCENARIO("Concurrent fast and slow path traffic never leaks group tokens or deadlocks") {
504497
const int concurrency = GENERATE(1, 2, 3);
505498
CAPTURE(concurrency);
@@ -598,12 +591,8 @@ namespace threading {
598591
// next acquire. Without this the next round's lock() re-raises slow_pending and
599592
// legitimately defers not-yet-drained fast waiters (slow path has priority);
600593
// that is expected scheduler behaviour, not a leak.
601-
const bool quiesced = wait_for(
602-
[&] {
603-
return Group::TestAccess::tokens(*groups[0]) == concurrency
604-
&& Group::TestAccess::tokens(*groups[1]) == concurrency;
605-
},
606-
std::chrono::seconds(10));
594+
const bool quiesced = wait_for_full_capacity(*groups[0], concurrency, std::chrono::seconds(10))
595+
&& wait_for_full_capacity(*groups[1], concurrency, std::chrono::seconds(10));
607596
REQUIRE(quiesced);
608597
}
609598

@@ -627,11 +616,11 @@ namespace threading {
627616

628617
// (b) No leaked/duplicated tokens, and the group is still usable.
629618
for (auto& g : groups) {
630-
CHECK(Group::TestAccess::tokens(*g) == concurrency);
631-
auto fresh = Group::TestAccess::try_acquire_running_lock(*g);
619+
CHECK(has_full_capacity(*g, concurrency));
620+
auto fresh = g->try_acquire_running_lock();
632621
CHECK(fresh != nullptr);
633622
fresh.reset();
634-
CHECK(Group::TestAccess::tokens(*g) == concurrency);
623+
CHECK(has_full_capacity(*g, concurrency));
635624
}
636625
}
637626

@@ -658,7 +647,7 @@ namespace threading {
658647

659648
WHEN("The fast path tries to acquire a running lock") {
660649
THEN("No token is handed out until the slow lock releases") {
661-
CHECK(Group::TestAccess::try_acquire_running_lock(*group) == nullptr);
650+
CHECK(group->try_acquire_running_lock() == nullptr);
662651
}
663652
}
664653
}
@@ -690,9 +679,12 @@ namespace threading {
690679
auto pool = std::make_unique<Pool>(*scheduler, pool_desc);
691680
auto group = make_group(1);
692681

693-
Group::TestAccess::park_publish(*group, make_test_task(), pool.get(), false);
682+
// A slow-path waiter blocks the fast path without holding a token.
683+
std::unique_ptr<Lock> slow_lock = group->lock(1, 1, [] {});
684+
CHECK_FALSE(group->try_submit(make_test_task(), pool.get(), false));
694685

695686
WHEN("The group is destroyed without draining the parked waiter") {
687+
slow_lock.reset();
696688
group.reset();
697689

698690
THEN("The pool can still shut down cleanly because external waiters were balanced") {
@@ -703,53 +695,6 @@ namespace threading {
703695
}
704696
}
705697

706-
SCENARIO("Releasing a locked slow-path lock drains a committed fast waiter when tokens are negative") {
707-
GIVEN("A group with one token, a locked slow-path holder, and a parked fast waiter") {
708-
auto group = make_group(1);
709-
710-
Group::TestAccess::set_capture_drains(*group, true);
711-
712-
std::unique_ptr<Lock> slow_lock = group->lock(1, 1, [] {});
713-
CHECK(slow_lock->lock() == true);
714-
715-
auto slot = Group::TestAccess::park_publish(*group, make_test_task(), nullptr, false);
716-
Group::TestAccess::park_reconcile(*group, slot);
717-
718-
WHEN("The slow lock releases while a fast waiter has already reserved a slot") {
719-
slow_lock.reset();
720-
721-
THEN("The committed waiter is drained and tokens return to concurrency") {
722-
auto captured = Group::TestAccess::take_captured_drains(*group);
723-
REQUIRE(captured.size() == 1);
724-
captured.front().lock.reset();
725-
726-
CHECK(Group::TestAccess::tokens(*group) == group->descriptor->concurrency);
727-
}
728-
}
729-
}
730-
}
731-
732-
SCENARIO("Park reconcile with a free token drains an earlier uncounted waiter") {
733-
GIVEN("A group with spare tokens and two parked fast waiters") {
734-
auto group = make_group(2);
735-
736-
Group::TestAccess::set_capture_drains(*group, true);
737-
738-
Group::TestAccess::park_publish(*group, make_test_task(), nullptr, false);
739-
auto slot2 = Group::TestAccess::park_publish(*group, make_test_task(), nullptr, false);
740-
741-
WHEN("The second waiter reconciles while the first is still uncounted") {
742-
Group::TestAccess::park_reconcile(*group, slot2);
743-
744-
THEN("The first waiter is opportunistically drained") {
745-
auto captured = Group::TestAccess::take_captured_drains(*group);
746-
REQUIRE(captured.size() == 1);
747-
captured.front().lock.reset();
748-
}
749-
}
750-
}
751-
}
752-
753698
SCENARIO("try_submit parks while slow-path waiters hold priority") {
754699
GIVEN("A group whose sole token is held by a slow-path lock") {
755700
auto scheduler = std::make_unique<Scheduler>(1);
@@ -777,13 +722,13 @@ namespace threading {
777722

778723
SCENARIO("try_acquire_running_lock returns nullptr when every token is in use") {
779724
GIVEN("A group with one token acquired via the fast path") {
780-
auto group = make_group(1);
781-
auto running = Group::TestAccess::try_acquire_running_lock(*group);
725+
auto group = make_group(1);
726+
auto running = group->try_acquire_running_lock();
782727
REQUIRE(running != nullptr);
783728

784729
WHEN("Another fast-path acquisition is attempted") {
785730
THEN("No second token is available") {
786-
CHECK(Group::TestAccess::try_acquire_running_lock(*group) == nullptr);
731+
CHECK(group->try_acquire_running_lock() == nullptr);
787732
}
788733
}
789734
}

0 commit comments

Comments
 (0)