From a4753f4e9fd35ca0326512d9999cc81c0ff3e103 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 23:18:26 +0200 Subject: [PATCH] Notify the closer while still holding the gate A call scope used to drop the lock after decrementing the in-flight count and notify afterwards. That left a window: the closer's predicate is already true once the count reaches zero, so a scope preempted between the unlock and the notify could come back to find the runtime torn down and signal a condition variable that no longer exists. ThreadSanitizer reports it as a broadcast racing the destroy. The decrement and the notify now happen under one lock guard. The closer cannot return from its wait until the scope releases the gate, so the condition variable is alive for as long as anything can signal it. Co-Authored-By: Claude Opus 5 --- .../cultmesh_quic_native.cpp | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp index a7326e68..14ebc344 100644 --- a/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp +++ b/native/GameCult.Mesh.Quic.Native/cultmesh_quic_native.cpp @@ -190,12 +190,16 @@ public: ++runtime_->active_calls; entered_ = true; } + // The notify stays under `gate`, and that is the whole point of it. Released + // first, the closer's predicate is already true, so it can return from its + // wait, run the teardown and destroy the runtime — condition variable + // included — before this thread reaches the notify. Holding the lock across + // both means the closer cannot leave `signal.wait` until this scope has let + // go, so the condition variable is still alive when it is signalled. ~CallScope() { if (!entered_) return; - { - std::lock_guard lock(runtime_->gate); - --runtime_->active_calls; - } + std::lock_guard lock(runtime_->gate); + --runtime_->active_calls; runtime_->signal.notify_all(); } CallScope(const CallScope&) = delete;