From ad84a78c566123bf47a551520d1f2c17138ffdfa Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 19 May 2026 11:19:52 +0100 Subject: [PATCH 1/4] implement AwaitableMutex for netfx --- src/StackExchange.Redis/AwaitableMutex.net.cs | 9 +- .../AwaitableMutex.netfx.cs | 318 +++++++++++++++++- .../AwaitableMutexTests.cs | 191 +++++++++++ 3 files changed, 513 insertions(+), 5 deletions(-) create mode 100644 tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs diff --git a/src/StackExchange.Redis/AwaitableMutex.net.cs b/src/StackExchange.Redis/AwaitableMutex.net.cs index ab6db2b09..501b6af36 100644 --- a/src/StackExchange.Redis/AwaitableMutex.net.cs +++ b/src/StackExchange.Redis/AwaitableMutex.net.cs @@ -1,12 +1,17 @@ using System.Threading; using System.Threading.Tasks; -// #if NET +#if NET namespace StackExchange.Redis; internal partial struct AwaitableMutex { private readonly int _timeoutMilliseconds; + + // note: this does not guarantee "fairness", but that's OK for our use-case - we mostly just want + // a sync+async awaitable mutex, which this does; the .NET Framework version has a hand-written + // implementation (see .netfx.cx for reasons), which *is* fair, but we'd rather not pay that overhead + // here. Good-enough-is. private readonly SemaphoreSlim _mutex; private partial AwaitableMutex(int timeoutMilliseconds) @@ -28,4 +33,4 @@ public partial ValueTask TryTakeAsync(CancellationToken cancellationToken) public partial void Release() => _mutex.Release(); } -// #endif +#endif diff --git a/src/StackExchange.Redis/AwaitableMutex.netfx.cs b/src/StackExchange.Redis/AwaitableMutex.netfx.cs index bb096b786..f2711e777 100644 --- a/src/StackExchange.Redis/AwaitableMutex.netfx.cs +++ b/src/StackExchange.Redis/AwaitableMutex.netfx.cs @@ -1,9 +1,321 @@ -#if !NET +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +#if !NET namespace StackExchange.Redis; -// compensating for the fact that netfx SemaphoreSlim is kinda janky -// (https://blog.marcgravell.com/2019/02/fun-with-spiral-of-death.html) +/* +Compensating for the fact that netfx SemaphoreSlim is kinda janky (https://blog.marcgravell.com/2019/02/fun-with-spiral-of-death.html). + +This uses a simple queue of sync/async callers, and assumes a reasonable caller (the original MutexSlim is more defensive, as +a general purpose public API). +*/ + internal partial struct AwaitableMutex { + private readonly State _state; + + private partial AwaitableMutex(int timeoutMilliseconds) + { + _state = new(timeoutMilliseconds); + } + + public partial void Dispose() => _state?.Dispose(); + + public partial bool IsAvailable => _state.IsAvailable; + public partial int TimeoutMilliseconds => _state.TimeoutMilliseconds; + + public partial bool TryTakeInstant() => _state.TryTakeInstant(); + + public partial ValueTask TryTakeAsync(CancellationToken cancellationToken) + => _state.TryTakeAsync(cancellationToken); + + public partial bool TryTakeSync() => _state.TryTakeSync(); + + public partial void Release() => _state.Release(); + + private sealed class State : IDisposable + { + private readonly Queue _queue = new(); + private bool _isHeld; + private bool _isDisposed; + + public State(int timeoutMilliseconds) + { + if (timeoutMilliseconds < Timeout.Infinite) ThrowOutOfRangeException(); + TimeoutMilliseconds = timeoutMilliseconds; + + static void ThrowOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(timeoutMilliseconds)); + } + + public int TimeoutMilliseconds { get; } + + public bool IsAvailable + { + get + { + lock (_queue) + { + return !_isDisposed && !_isHeld && _queue.Count == 0; + } + } + } + + public bool TryTakeInstant() + { + lock (_queue) + { + ThrowIfDisposed(); + return TryTakeInsideLock(); + } + } + + public bool TryTakeSync() + { + var start = GetTime(); + SyncPendingCaller pending; + lock (_queue) + { + ThrowIfDisposed(); + if (TryTakeInsideLock()) return true; + if (TimeoutMilliseconds == 0) return false; + + pending = new(start, TimeoutMilliseconds); + _queue.Enqueue(pending); + } + + return pending.Wait(); + } + + public ValueTask TryTakeAsync(CancellationToken cancellationToken) + { + lock (_queue) + { + ThrowIfDisposed(); + if (cancellationToken.IsCancellationRequested) return AsyncPendingCaller.Canceled(); + if (TryTakeInsideLock()) return new ValueTask(true); + if (TimeoutMilliseconds == 0) return new ValueTask(false); + if (cancellationToken.IsCancellationRequested) return AsyncPendingCaller.Canceled(); + + var pending = new AsyncPendingCaller(TimeoutMilliseconds, cancellationToken); + _queue.Enqueue(pending); + return new ValueTask(pending.Task); + } + } + + public void Release() + { + lock (_queue) + { + ThrowIfDisposed(); + if (!_isHeld) ThrowNotHeld(); + + while (_queue.Count != 0) + { + if (_queue.Dequeue().TryGrant()) return; + } + + _isHeld = false; + } + + static void ThrowNotHeld() => throw new SemaphoreFullException(); + } + + private bool TryTakeInsideLock() + { + if (_isHeld || _queue.Count != 0) return false; + _isHeld = true; + return true; + } + + public void Dispose() + { + lock (_queue) + { + if (_isDisposed) return; + + _isDisposed = true; + _isHeld = false; + while (_queue.Count != 0) + { + _queue.Dequeue().Abort(); + } + } + } + + private void ThrowIfDisposed() + { + if (_isDisposed) ThrowDisposed(); + } + + private static void ThrowDisposed() => throw new ObjectDisposedException(nameof(AwaitableMutex)); + + private static uint GetTime() => (uint)Environment.TickCount; + + private static int GetRemainingTimeout(uint startTime, int originalTimeoutMilliseconds) + { + if (originalTimeoutMilliseconds == Timeout.Infinite) return Timeout.Infinite; + + var elapsedMilliseconds = GetTime() - startTime; + if (elapsedMilliseconds > int.MaxValue) return 0; + + var remaining = originalTimeoutMilliseconds - (int)elapsedMilliseconds; + return remaining <= 0 ? 0 : remaining; + } + + private interface IPendingCaller + { + bool TryGrant(); + void Abort(); + } + + private sealed class SyncPendingCaller : IPendingCaller + { + private readonly uint _start; + private readonly int _timeoutMilliseconds; + private bool _isComplete; + private bool _wasGranted; + private bool _wasAborted; + + public SyncPendingCaller(uint start, int timeoutMilliseconds) + { + _start = start; + _timeoutMilliseconds = timeoutMilliseconds; + } + + public bool Wait() + { + lock (this) + { + while (!_isComplete) + { + var remaining = GetRemainingTimeout(_start, _timeoutMilliseconds); + if (remaining == 0) + { + _isComplete = true; + return false; + } + + if (remaining == Timeout.Infinite) + { + Monitor.Wait(this); + } + else + { + Monitor.Wait(this, remaining); + } + } + + if (_wasAborted) ThrowDisposed(); + return _wasGranted; + } + } + + public bool TryGrant() + { + lock (this) + { + if (_isComplete) return false; + _wasGranted = true; + _isComplete = true; + Monitor.Pulse(this); + return true; + } + } + + public void Abort() + { + lock (this) + { + if (_isComplete) return; + _wasAborted = true; + _isComplete = true; + Monitor.Pulse(this); + } + } + } + + private sealed class AsyncPendingCaller : TaskCompletionSource, IPendingCaller + { + private static readonly TimerCallback s_onTimeout = state => ((AsyncPendingCaller)state!).TryComplete(CompletionState.TimedOut); + private static readonly Action s_onCanceled = state => ((AsyncPendingCaller)state!).TryComplete(CompletionState.Canceled); + private static readonly Task s_canceledTask = CreateCanceledTask(); + + private readonly CancellationTokenRegistration _cancellation; + private readonly Timer? _timeout; + private int _completionState; + + public AsyncPendingCaller(int timeoutMilliseconds, CancellationToken cancellationToken) + : base(TaskCreationOptions.RunContinuationsAsynchronously) + { + if (timeoutMilliseconds != Timeout.Infinite) + { + _timeout = new Timer(s_onTimeout, this, timeoutMilliseconds, Timeout.Infinite); + } + + if (cancellationToken.CanBeCanceled) + { + _cancellation = cancellationToken.Register(s_onCanceled, this); + } + } + + public static ValueTask Canceled() => new(s_canceledTask); + + public bool TryGrant() => TryComplete(CompletionState.Granted); + + public void Abort() => TryComplete(CompletionState.Disposed); + + private bool TryComplete(CompletionState completionState) + { + var newState = (int)completionState; + if (Interlocked.CompareExchange(ref _completionState, newState, (int)CompletionState.Pending) != (int)CompletionState.Pending) + { + return false; + } + + if (completionState != CompletionState.TimedOut) _timeout?.Dispose(); + if (completionState != CompletionState.Canceled) _cancellation.Dispose(); + Complete(completionState); + return true; + } + + private void Complete(CompletionState completionState) + { + switch (completionState) + { + case CompletionState.Granted: + TrySetResult(true); + break; + case CompletionState.TimedOut: + TrySetResult(false); + break; + case CompletionState.Canceled: + TrySetCanceled(); + break; + case CompletionState.Disposed: + TrySetException(new ObjectDisposedException(nameof(AwaitableMutex))); + break; + } + } + + private static Task CreateCanceledTask() + { + var source = new TaskCompletionSource(); + source.SetCanceled(); + return source.Task; + } + + private enum CompletionState + { + Pending = 0, + Granted = 1, + TimedOut = 2, + Canceled = 3, + Disposed = 4, + } + } + } } #endif diff --git a/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs b/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs new file mode 100644 index 000000000..9b9804523 --- /dev/null +++ b/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace StackExchange.Redis.Tests; + +public class AwaitableMutexTests +{ + private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); + + [Fact] + public void IsolatedSyncSuccessAndReturn() + { + using var mutex = AwaitableMutex.Create(timeoutMilliseconds: 100); + + for (var i = 0; i < 5; i++) + { + Assert.True(mutex.IsAvailable); + Assert.True(i % 2 == 0 ? mutex.TryTakeInstant() : mutex.TryTakeSync()); + Assert.False(mutex.IsAvailable); + mutex.Release(); + } + + Assert.True(mutex.IsAvailable); + } + + [Fact] + public async Task SyncCallerTimesOutWhileHeld() + { + using var mutex = AwaitableMutex.Create(timeoutMilliseconds: 50); + Assert.True(mutex.TryTakeInstant()); + + var result = await WithTimeout(Task.Run(() => mutex.TryTakeSync())); + + Assert.False(result); + Assert.False(mutex.IsAvailable); + mutex.Release(); + Assert.True(mutex.IsAvailable); + } + + [Fact] + public async Task AsyncCallerTimesOutWhileHeld() + { + using var mutex = AwaitableMutex.Create(timeoutMilliseconds: 50); + Assert.True(mutex.TryTakeInstant()); + + var result = await WithTimeout(mutex.TryTakeAsync().AsTask()); + + Assert.False(result); + Assert.False(mutex.IsAvailable); + mutex.Release(); + Assert.True(mutex.IsAvailable); + } + + [Fact] + public void DisposalPreventsNewAcquisitions() + { + var mutex = AwaitableMutex.Create(timeoutMilliseconds: 100); + Assert.True(mutex.TryTakeInstant()); + + mutex.Dispose(); + + Assert.False(mutex.IsAvailable); + Assert.Throws(() => mutex.TryTakeInstant()); + Assert.Throws(() => mutex.TryTakeSync()); + Assert.Throws(() => + { + _ = mutex.TryTakeAsync(); + }); + Assert.Throws(() => mutex.Release()); + } + + [Fact] + public async Task MixedSyncAndAsyncWaitersAreReleased() + { + const int Iterations = 100; + using var mutex = AwaitableMutex.Create(timeoutMilliseconds: 10_000); + + for (var i = 0; i < Iterations; i++) + { + await Core(i, mutex); + } + + static async Task Core(int iteration, AwaitableMutex mutex) + { + Assert.True(mutex.TryTakeInstant()); + + var order = new List(); + var expected = new[] + { + $"{iteration}:sync-1", + $"{iteration}:async-1", + $"{iteration}:sync-2", + $"{iteration}:async-2", + }; + + var sync1 = StartSyncWaiter(mutex, expected[0], order, out var sync1Thread); + WaitForBlocked(sync1Thread); + + var async1 = StartAsyncWaiter(mutex, expected[1], order); + Assert.False(async1.IsCompleted); + + var sync2 = StartSyncWaiter(mutex, expected[2], order, out var sync2Thread); + WaitForBlocked(sync2Thread); + + var async2 = StartAsyncWaiter(mutex, expected[3], order); + Assert.False(async2.IsCompleted); + + mutex.Release(); + + await WithTimeout(Task.WhenAll(sync1, async1, sync2, async2)); + + // SemaphoreSlim does not guarantee FIFO ordering; this only verifies that every queued waiter arrives. + order.Sort(StringComparer.Ordinal); + Array.Sort(expected, StringComparer.Ordinal); + Assert.Equal(expected, order); + Assert.True(mutex.IsAvailable); + } + } + + private static Task StartSyncWaiter(AwaitableMutex mutex, string name, List order, out Thread thread) + { + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = new ManualResetEventSlim(); + thread = new Thread(() => + { + started.Set(); + try + { + if (!mutex.TryTakeSync()) throw new TimeoutException(); + + Add(order, name); + mutex.Release(); + source.TrySetResult(true); + } + catch (Exception ex) + { + source.TrySetException(ex); + } + }) + { + IsBackground = true, + Name = name, + }; + thread.Start(); + + Assert.True(started.Wait(TestTimeout), $"{name} did not start"); + return source.Task; + } + + private static async Task StartAsyncWaiter(AwaitableMutex mutex, string name, List order) + { + if (!await mutex.TryTakeAsync().AsTask()) throw new TimeoutException(); + + Add(order, name); + mutex.Release(); + } + + private static void WaitForBlocked(Thread thread) + { + Assert.True( + SpinWait.SpinUntil(() => (thread.ThreadState & ThreadState.WaitSleepJoin) != 0, TestTimeout), + $"{thread.Name} did not block"); + } + + private static void Add(List order, string name) + { + lock (order) + { + order.Add(name); + } + } + + private static async Task WithTimeout(Task task) + { + var timeout = Task.Delay(TestTimeout); + var first = await Task.WhenAny(task, timeout); + Assert.Same(task, first); + await task; + } + + private static async Task WithTimeout(Task task) + { + var timeout = Task.Delay(TestTimeout); + var first = await Task.WhenAny(task, timeout); + Assert.Same(task, first); + return await task; + } +} From bc6872892525e903b37fffa3ff37e18c5d81319c Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 19 May 2026 11:46:32 +0100 Subject: [PATCH 2/4] optimize successful sync take path --- .../AwaitableMutex.netfx.cs | 72 ++++++++++++++----- 1 file changed, 55 insertions(+), 17 deletions(-) diff --git a/src/StackExchange.Redis/AwaitableMutex.netfx.cs b/src/StackExchange.Redis/AwaitableMutex.netfx.cs index f2711e777..1cb4f509a 100644 --- a/src/StackExchange.Redis/AwaitableMutex.netfx.cs +++ b/src/StackExchange.Redis/AwaitableMutex.netfx.cs @@ -65,36 +65,46 @@ public bool IsAvailable public bool TryTakeInstant() { - lock (_queue) + bool lockTaken = false; + try { - ThrowIfDisposed(); + Monitor.TryEnter(_queue, 0, ref lockTaken); + if (!lockTaken) return false; + return TryTakeInsideLock(); } + finally + { + if (lockTaken) Monitor.Exit(_queue); + } } public bool TryTakeSync() { - var start = GetTime(); - SyncPendingCaller pending; - lock (_queue) + bool lockTaken = false; + try { - ThrowIfDisposed(); - if (TryTakeInsideLock()) return true; - if (TimeoutMilliseconds == 0) return false; - - pending = new(start, TimeoutMilliseconds); - _queue.Enqueue(pending); + Monitor.TryEnter(_queue, 0, ref lockTaken); + if (lockTaken && TryTakeInsideLock()) return true; + } + finally + { + if (lockTaken) Monitor.Exit(_queue); } - return pending.Wait(); + return TryTakeSyncSlow(); } public ValueTask TryTakeAsync(CancellationToken cancellationToken) { lock (_queue) { - ThrowIfDisposed(); - if (cancellationToken.IsCancellationRequested) return AsyncPendingCaller.Canceled(); + if (cancellationToken.IsCancellationRequested) + { + ThrowIfDisposed(); + return AsyncPendingCaller.Canceled(); + } + if (TryTakeInsideLock()) return new ValueTask(true); if (TimeoutMilliseconds == 0) return new ValueTask(false); if (cancellationToken.IsCancellationRequested) return AsyncPendingCaller.Canceled(); @@ -125,18 +135,46 @@ public void Release() private bool TryTakeInsideLock() { + ThrowIfDisposed(); if (_isHeld || _queue.Count != 0) return false; _isHeld = true; return true; } + private bool TryTakeSyncSlow() + { + if (TimeoutMilliseconds == 0) return false; + + var start = GetTime(); + SyncPendingCaller? pending = null; + bool lockTaken = false; + try + { + Monitor.TryEnter(_queue, TimeoutMilliseconds, ref lockTaken); + if (!lockTaken) return false; + if (TryTakeInsideLock()) return true; + + var remaining = GetRemainingTimeout(start, TimeoutMilliseconds); + if (remaining == 0) return false; + + pending = new SyncPendingCaller(start, TimeoutMilliseconds); + _queue.Enqueue(pending); + } + finally + { + if (lockTaken) Monitor.Exit(_queue); + } + + return pending!.Wait(); + } + public void Dispose() { + if (_isDisposed) return; + _isDisposed = true; + lock (_queue) { - if (_isDisposed) return; - - _isDisposed = true; _isHeld = false; while (_queue.Count != 0) { From 422efab091d6bb8a442e4087722cf9f2f16a11fa Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 19 May 2026 11:47:42 +0100 Subject: [PATCH 3/4] words --- src/StackExchange.Redis/AwaitableMutex.netfx.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/StackExchange.Redis/AwaitableMutex.netfx.cs b/src/StackExchange.Redis/AwaitableMutex.netfx.cs index 1cb4f509a..a94f329c6 100644 --- a/src/StackExchange.Redis/AwaitableMutex.netfx.cs +++ b/src/StackExchange.Redis/AwaitableMutex.netfx.cs @@ -84,6 +84,7 @@ public bool TryTakeSync() bool lockTaken = false; try { + // try to acquire uncontested lock - that way we can avoid checking the time Monitor.TryEnter(_queue, 0, ref lockTaken); if (lockTaken && TryTakeInsideLock()) return true; } From bbed4510cc95b324561e8c1bbb08df79d58494a8 Mon Sep 17 00:00:00 2001 From: Marc Gravell Date: Tue, 19 May 2026 12:03:49 +0100 Subject: [PATCH 4/4] optimize async path for immediate acquisition --- .../AwaitableMutex.netfx.cs | 59 ++++++++++++++----- .../AwaitableMutexTests.cs | 7 +-- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/StackExchange.Redis/AwaitableMutex.netfx.cs b/src/StackExchange.Redis/AwaitableMutex.netfx.cs index a94f329c6..7267f8043 100644 --- a/src/StackExchange.Redis/AwaitableMutex.netfx.cs +++ b/src/StackExchange.Redis/AwaitableMutex.netfx.cs @@ -97,18 +97,44 @@ public bool TryTakeSync() } public ValueTask TryTakeAsync(CancellationToken cancellationToken) + { + bool lockTaken = false; + try + { + // try to acquire uncontested lock - that way we can avoid allocating the pending caller + Monitor.TryEnter(_queue, 0, ref lockTaken); + if (lockTaken) + { + if (_isDisposed) return DisposedAsync(); + if (cancellationToken.IsCancellationRequested) + { + return CanceledAsync(cancellationToken); + } + + if (TryTakeInsideLockCore()) return new ValueTask(true); + } + } + finally + { + if (lockTaken) Monitor.Exit(_queue); + } + + return TryTakeAsyncSlow(cancellationToken); + } + + private ValueTask TryTakeAsyncSlow(CancellationToken cancellationToken) { lock (_queue) { + if (_isDisposed) return DisposedAsync(); if (cancellationToken.IsCancellationRequested) { - ThrowIfDisposed(); - return AsyncPendingCaller.Canceled(); + return CanceledAsync(cancellationToken); } - if (TryTakeInsideLock()) return new ValueTask(true); + if (TryTakeInsideLockCore()) return new ValueTask(true); if (TimeoutMilliseconds == 0) return new ValueTask(false); - if (cancellationToken.IsCancellationRequested) return AsyncPendingCaller.Canceled(); + if (cancellationToken.IsCancellationRequested) return CanceledAsync(cancellationToken); var pending = new AsyncPendingCaller(TimeoutMilliseconds, cancellationToken); _queue.Enqueue(pending); @@ -137,6 +163,11 @@ public void Release() private bool TryTakeInsideLock() { ThrowIfDisposed(); + return TryTakeInsideLockCore(); + } + + private bool TryTakeInsideLockCore() + { if (_isHeld || _queue.Count != 0) return false; _isHeld = true; return true; @@ -191,6 +222,12 @@ private void ThrowIfDisposed() private static void ThrowDisposed() => throw new ObjectDisposedException(nameof(AwaitableMutex)); + private static ValueTask DisposedAsync() + => new(Task.FromException(new ObjectDisposedException(nameof(AwaitableMutex)))); + + private static ValueTask CanceledAsync(CancellationToken cancellationToken) + => new(Task.FromCanceled(cancellationToken)); + private static uint GetTime() => (uint)Environment.TickCount; private static int GetRemainingTimeout(uint startTime, int originalTimeoutMilliseconds) @@ -280,8 +317,8 @@ private sealed class AsyncPendingCaller : TaskCompletionSource, IPendingCa { private static readonly TimerCallback s_onTimeout = state => ((AsyncPendingCaller)state!).TryComplete(CompletionState.TimedOut); private static readonly Action s_onCanceled = state => ((AsyncPendingCaller)state!).TryComplete(CompletionState.Canceled); - private static readonly Task s_canceledTask = CreateCanceledTask(); + private readonly CancellationToken _cancellationToken; private readonly CancellationTokenRegistration _cancellation; private readonly Timer? _timeout; private int _completionState; @@ -289,6 +326,7 @@ private sealed class AsyncPendingCaller : TaskCompletionSource, IPendingCa public AsyncPendingCaller(int timeoutMilliseconds, CancellationToken cancellationToken) : base(TaskCreationOptions.RunContinuationsAsynchronously) { + _cancellationToken = cancellationToken; if (timeoutMilliseconds != Timeout.Infinite) { _timeout = new Timer(s_onTimeout, this, timeoutMilliseconds, Timeout.Infinite); @@ -300,8 +338,6 @@ public AsyncPendingCaller(int timeoutMilliseconds, CancellationToken cancellatio } } - public static ValueTask Canceled() => new(s_canceledTask); - public bool TryGrant() => TryComplete(CompletionState.Granted); public void Abort() => TryComplete(CompletionState.Disposed); @@ -331,7 +367,7 @@ private void Complete(CompletionState completionState) TrySetResult(false); break; case CompletionState.Canceled: - TrySetCanceled(); + TrySetCanceled(_cancellationToken); break; case CompletionState.Disposed: TrySetException(new ObjectDisposedException(nameof(AwaitableMutex))); @@ -339,13 +375,6 @@ private void Complete(CompletionState completionState) } } - private static Task CreateCanceledTask() - { - var source = new TaskCompletionSource(); - source.SetCanceled(); - return source.Task; - } - private enum CompletionState { Pending = 0, diff --git a/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs b/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs index 9b9804523..f230ff348 100644 --- a/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs +++ b/tests/StackExchange.Redis.Tests/AwaitableMutexTests.cs @@ -55,7 +55,7 @@ public async Task AsyncCallerTimesOutWhileHeld() } [Fact] - public void DisposalPreventsNewAcquisitions() + public async Task DisposalPreventsNewAcquisitions() { var mutex = AwaitableMutex.Create(timeoutMilliseconds: 100); Assert.True(mutex.TryTakeInstant()); @@ -65,10 +65,7 @@ public void DisposalPreventsNewAcquisitions() Assert.False(mutex.IsAvailable); Assert.Throws(() => mutex.TryTakeInstant()); Assert.Throws(() => mutex.TryTakeSync()); - Assert.Throws(() => - { - _ = mutex.TryTakeAsync(); - }); + await Assert.ThrowsAsync(async () => await mutex.TryTakeAsync().AsTask()); Assert.Throws(() => mutex.Release()); }