Sync path must wait for completion, not merely for a fault (#3212)
ExecuteSyncImpl stopped waiting as soon as it observed IResultBox.IsFaulted. That is only safe when the message faulted inline on the calling thread during the write - in which case the completion's PulseAll happened under our own (reentrant) lock and is already gone. When another thread faults the box, the two steps are separate and unsynchronized: ResultProcessor.SetException publishes the exception with no lock, and the matching pulse only follows later, from Message.Complete -> ActivateContinuations. So a caller preempted between the write reaching the wire (inside TryPushMessageToBridgeSync) and the IsFaulted read can see the fault, skip the wait, and recycle the box into the [ThreadStatic] pool while the reader thread is still on its way to PulseAll on that very object. The next synchronous call on that thread borrows the same box, parks in Monitor.Wait, and is woken by that stale pulse: it returns with neither result nor exception. Its real reply then lands on the already-recycled box, so every later reply on the thread is shifted by one - and because the pool is thread-static, the damage survives new multiplexers and new servers. Track completion explicitly instead. ActivateContinuations sets _completed inside the lock, immediately before the pulse, and the waiter leaves only on _completed - so a box can never be recycled while a pulse is inbound, and a pulse the waiter did not cause no longer shortens the wait (the original deadline is preserved across such wakes). Recycling clears the flag. Reported against Garnet CI as microsoft/garnet#2110, where it surfaced as one test losing its exception and a later test in the same fixture receiving it.
M
Marc Gravell committed
0ee60b587931c65ff6c7c5f10ebc19cc0f6a263a
Parent: 7648fc8
Committed by GitHub <noreply@github.com>
on 9/10/2026, 9:12:14 AM