using Esiur.Core; using Esiur.Data.Types; using System.Reflection; using System.Runtime.CompilerServices; namespace Esiur.Tests.Unit; public class AsyncReplyConcurrencyTests { [Fact] public void CompletedRepliesKeepCallbackAndWaiterStorageLazy() { var reply = new AsyncReply(42); var awaiter = reply.GetAwaiter(); Assert.True(reply.Ready); Assert.True(awaiter.IsCompleted); Assert.Equal(42, awaiter.GetResult()); Assert.Null(GetBaseField(reply, "callbacks")); Assert.Null(GetBaseField(reply, "errorCallbacks")); Assert.Null(GetBaseField(reply, "completionEvent")); } [Fact] public void CompletedReplyAllocationStaysWithinObjectAndLockBudget() { const int count = 4096; const int maximumBytesPerReply = 224; var replies = new AsyncReply[count]; // Warm up constructors and property access before measuring this thread. for (var i = 0; i < 32; i++) _ = new AsyncReply(i).Ready; var before = GC.GetAllocatedBytesForCurrentThread(); for (var i = 0; i < replies.Length; i++) replies[i] = new AsyncReply(i); var allocated = GC.GetAllocatedBytesForCurrentThread() - before; GC.KeepAlive(replies); Assert.True( allocated < count * (long)maximumBytesPerReply, $"Completed replies allocated {allocated / (double)count:N1} bytes each."); } [Fact] public void TriggerErrorWithoutAHandlerIsRetainedAndObservedByWaiters() { var reply = new AsyncReply(); var source = new InvalidOperationException("synchronous failure"); var triggerException = Record.Exception(() => reply.TriggerError(source)); Assert.Null(triggerException); Assert.False(reply.Ready); Assert.True(reply.Failed); var stored = Assert.IsType(reply.Exception); Assert.Same(source, stored.InnerException); Assert.Same(stored, Assert.Throws(() => reply.Wait())); Assert.Same(stored, Assert.Throws(() => reply.Wait(10))); AsyncException? observed = null; reply.Error(error => observed = error); Assert.Same(stored, observed); } [Fact] public async Task CustomAsyncBuilderRetainsAnErrorThrownBeforeTheFirstAwait() { AsyncReply? reply = null; var creationException = Record.Exception(() => reply = FailBeforeFirstAwait(fail: true)); Assert.Null(creationException); Assert.NotNull(reply); Assert.True(reply!.Failed); await Assert.ThrowsAsync(async () => _ = await reply); } [Fact] public async Task CustomAsyncBuilderPreservesAsyncExceptionAfterSuspension() { var gate = new AsyncReply(); var reply = FailAfterFirstAwait(gate); var observation = AwaitAsTask(reply); gate.Trigger(null!); var exception = await Assert.ThrowsAsync(() => observation); Assert.IsType(exception.InnerException); } [Fact] public async Task CustomAsyncBuilderPublishesSuspensionBeforeInlineContinuation() { AsyncReply? reply = null; var creationException = Record.Exception(() => reply = FailAfterInlineCompletion()); Assert.Null(creationException); var exception = await Assert.ThrowsAsync(async () => _ = await reply!); Assert.IsType(exception.InnerException); } [Fact] public void StreamErrorsReachTheBaseTerminalStateWithoutAnErrorHandler() { static AsyncReply CompletedControl() => new AsyncReply((object)null!); var stream = new AsyncStreamReply( StreamMode.Push, CompletedControl, CompletedControl, CompletedControl, CompletedControl); var expected = new AsyncException(ErrorType.Exception, 0, "stream failed"); stream.TriggerError(expected); Assert.True(stream.Failed); Assert.Same(expected, stream.Exception); Assert.Same(expected, Assert.Throws(() => stream.Wait())); } [Theory] [InlineData(false)] [InlineData(true)] public void TerminalStateWakesEverySynchronousWaiter(bool fail) { const int waiterCount = 8; var reply = new AsyncReply(); var results = new int[waiterCount]; var errors = new Exception?[waiterCount]; var threads = new Thread[waiterCount]; for (var i = 0; i < threads.Length; i++) { var index = i; threads[i] = new Thread(() => { try { results[index] = reply.Wait(); } catch (Exception exception) { errors[index] = exception; } }) { IsBackground = true }; threads[i].Start(); } var allWaiting = SpinWait.SpinUntil( () => threads.All(thread => (thread.ThreadState & System.Threading.ThreadState.WaitSleepJoin) != 0), millisecondsTimeout: 5000); if (fail) reply.TriggerError(new InvalidOperationException("failed")); else reply.Trigger(73); var allJoined = threads.Select(thread => thread.Join(2000)).ToArray(); Assert.True(allWaiting, "Not all test threads reached the blocking wait."); Assert.All(allJoined, joined => Assert.True(joined, "A synchronous waiter was not released.")); if (fail) Assert.All(errors, error => Assert.IsType(error)); else { Assert.All(results, value => Assert.Equal(73, value)); Assert.All(errors, Assert.Null); } } [Fact] public void CompletionCallbacksRunOutsideTheReplyLock() { var reply = new AsyncReply(); reply.Then(_ => { var concurrentRegistration = Task.Run(() => reply.Then(__ => { })); if (!concurrentRegistration.Wait(2000)) throw new TimeoutException("Concurrent callback registration was blocked by the completion callback."); }); Assert.Null(Record.Exception(() => reply.Trigger(1))); } [Fact] public void ErrorRegistrationRacingWithFailureInvokesExactlyOnce() { for (var iteration = 0; iteration < 500; iteration++) { var reply = new AsyncReply(); var expected = new AsyncException(ErrorType.Exception, 0, "failed"); AsyncException? observed = null; var calls = 0; Parallel.Invoke( () => reply.Error(error => { observed = error; Interlocked.Increment(ref calls); }), () => reply.TriggerError(expected)); Assert.Equal(1, Volatile.Read(ref calls)); Assert.Same(expected, observed); } } [Fact] public void AwaiterContinuationIsNotLostDuringCompletionRace() { for (var iteration = 0; iteration < 500; iteration++) { var reply = new AsyncReply(); var awaiter = reply.GetAwaiter(); using var continued = new ManualResetEventSlim(false); Parallel.Invoke( () => awaiter.OnCompleted(continued.Set), () => reply.Trigger(iteration)); Assert.True(continued.Wait(2000), "The awaiter continuation was lost."); Assert.True(awaiter.IsCompleted); Assert.Equal(iteration, awaiter.GetResult()); } } private static object? GetBaseField(AsyncReply reply, string name) => typeof(AsyncReply) .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic) ?.GetValue(reply); private static async AsyncReply FailBeforeFirstAwait(bool fail) { if (fail) throw new InvalidOperationException("failed before await"); await Task.Yield(); return 1; } private static async AsyncReply FailAfterFirstAwait(AsyncReply gate) { await gate; throw new InvalidOperationException("failed after await"); } private static async Task AwaitAsTask(AsyncReply reply) => await reply; private static async AsyncReply FailAfterInlineCompletion() { await new InlineCompletion(); throw new InvalidOperationException("failed after inline continuation"); } private readonly struct InlineCompletion { public Awaiter GetAwaiter() => new Awaiter(); public readonly struct Awaiter : INotifyCompletion { public bool IsCompleted => false; public void GetResult() { } public void OnCompleted(Action continuation) => continuation(); } } }