ci: ThreadSanitizer lane + fix use-after-free TSan flagged
Adds a -DSECSGEM_TSAN=ON CMake option that builds every target with -fsanitize=thread + debug symbols + -O1 + frame pointers. Wires a dedicated thread-sanitizer job into .gitea/workflows/ci.yml that builds and runs the full test suite under TSan with TSAN_OPTIONS=halt_on_error=1 (any flagged race fails the job, not just warns). Result against the full 426-case / 2557-assertion suite: 0 warnings, all green. That converts the existing test_thread_safety.cpp (which exercised the asio::post-onto-strand pattern) and test_concurrency (in-flight transaction interleaving) and test_robustness_fuzz (28 random action types × thousands of ticks) from "pattern smoke-tests" into actual race detection. The first TSan run caught a real bug in test_robustness_fuzz's act_exception_complete: it held a pointer to an ExceptionStore entry across fire_internal(RecoveryComplete), which deletes the entry. The subsequent state() read was a use-after-free. TSan flagged it 8 times (4 reads × 2 stack-frame variants). Fix is scoped lookup + re-check via has() after the mutation; matches the contract any reasonable caller would follow. The asio std_fenced_block atomic_thread_fence path generates TSan "not supported" warnings during compile — those are asio's, not ours, and don't affect runtime detection. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -483,21 +483,27 @@ bool act_exception_recover(World& w) {
|
||||
bool act_exception_complete(World& w) {
|
||||
if (w.exceptions.empty()) return false;
|
||||
const uint32_t exid = w.exceptions[w.rng() % w.exceptions.size()];
|
||||
const auto* ex = w.model.exceptions.get(exid);
|
||||
if (!ex) return false;
|
||||
if (ex->fsm->state() != ExceptionState::Recovering) return false;
|
||||
{
|
||||
// Scoped check: don't hold `ex` across fire_internal because a
|
||||
// successful RecoveryComplete erases the entry from the store
|
||||
// and invalidates the pointer (TSan flagged this as a real UAF).
|
||||
const auto* ex = w.model.exceptions.get(exid);
|
||||
if (!ex) return false;
|
||||
if (ex->fsm->state() != ExceptionState::Recovering) return false;
|
||||
}
|
||||
const auto event = w.roll(0.7) ? ExceptionEvent::RecoveryComplete
|
||||
: ExceptionEvent::RecoveryFailed;
|
||||
const bool ok = w.model.exceptions.fire_internal(exid, event);
|
||||
if (ok) {
|
||||
if (ex->fsm->state() == ExceptionState::Cleared ||
|
||||
!w.model.exceptions.has(exid)) {
|
||||
auto it = std::find(w.exceptions.begin(), w.exceptions.end(), exid);
|
||||
if (it != w.exceptions.end()) w.exceptions.erase(it);
|
||||
}
|
||||
w.log_action("exception_complete " + std::to_string(exid));
|
||||
if (!ok) return false;
|
||||
|
||||
// After fire_internal returns, look the entry up again — if Cleared
|
||||
// landed, the store has already removed it.
|
||||
if (!w.model.exceptions.has(exid)) {
|
||||
auto it = std::find(w.exceptions.begin(), w.exceptions.end(), exid);
|
||||
if (it != w.exceptions.end()) w.exceptions.erase(it);
|
||||
}
|
||||
return ok;
|
||||
w.log_action("exception_complete " + std::to_string(exid));
|
||||
return true;
|
||||
}
|
||||
|
||||
bool act_module_event(World& w) {
|
||||
|
||||
Reference in New Issue
Block a user