From 87847719643d1221b4f3fd8aa21522172899205f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 22 Dec 2025 16:42:10 -0600 Subject: [PATCH 1/2] Fix WithinAsync timeout not propagating to EventFilter across async boundaries Use AsyncLocal to properly propagate WithinAsync timeout to EventFilter and other async operations. The previous implementation used a plain instance field (_testState.End) which could have memory visibility issues when accessed from different threads across await boundaries. The fix: - Adds AsyncLocal field that flows through async contexts - WithinAsync sets/restores both instance field and AsyncLocal - Remaining and RemainingOr check AsyncLocal first, then instance field - Preserves backward compatibility with sync code paths --- src/core/Akka.TestKit/TestKitBase.cs | 30 ++++++++++++++++++++- src/core/Akka.TestKit/TestKitBase_Within.cs | 4 +++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/core/Akka.TestKit/TestKitBase.cs b/src/core/Akka.TestKit/TestKitBase.cs index 6c6f188cde9..0e95ac0f908 100644 --- a/src/core/Akka.TestKit/TestKitBase.cs +++ b/src/core/Akka.TestKit/TestKitBase.cs @@ -28,6 +28,10 @@ namespace Akka.TestKit /// public abstract partial class TestKitBase : IActorRefFactory { + // AsyncLocal for proper timeout propagation across async boundaries. + // This ensures WithinAsync timeout flows correctly to EventFilter and other async operations. + private static readonly AsyncLocal _asyncLocalEnd = new(); + private class TestState { public TestState() @@ -445,9 +449,21 @@ public TimeSpan Remaining { get { + // Check AsyncLocal first (async context takes precedence) + var asyncEnd = _asyncLocalEnd.Value; + if (asyncEnd.HasValue) + { + if (asyncEnd < TimeSpan.Zero) + throw new InvalidOperationException($"End can not be negative, was: {asyncEnd}"); + + var asyncRemaining = asyncEnd.Value - Now; + return asyncRemaining < TimeSpan.Zero ? TimeSpan.Zero : asyncRemaining; + } + + // Fallback to instance field if(_testState.End is null) throw new InvalidOperationException(@"Remaining may not be called outside of ""within"""); - + if (_testState.End < TimeSpan.Zero) throw new InvalidOperationException($"End can not be negative, was: {_testState.End}"); @@ -466,6 +482,18 @@ public TimeSpan Remaining /// TBD protected TimeSpan RemainingOr(TimeSpan duration) { + // Check AsyncLocal first (async context takes precedence for proper timeout propagation) + var asyncEnd = _asyncLocalEnd.Value; + if (asyncEnd.HasValue) + { + if (asyncEnd < TimeSpan.Zero) + throw new InvalidOperationException($"End can not be negative, was: {asyncEnd}"); + + var asyncRemaining = asyncEnd.Value - Now; + return asyncRemaining < TimeSpan.Zero ? TimeSpan.Zero : asyncRemaining; + } + + // Fallback to instance field for backward compatibility with sync code paths if (!_testState.End.HasValue) return duration; if (_testState.End < TimeSpan.Zero) throw new InvalidOperationException($"End can not be negative, was: {_testState.End}"); diff --git a/src/core/Akka.TestKit/TestKitBase_Within.cs b/src/core/Akka.TestKit/TestKitBase_Within.cs index 3d78d480c17..b08b2b6f50b 100644 --- a/src/core/Akka.TestKit/TestKitBase_Within.cs +++ b/src/core/Akka.TestKit/TestKitBase_Within.cs @@ -293,7 +293,10 @@ public async Task WithinAsync( var maxDiff = max.Min(rem); var prevEnd = _testState.End; + var prevAsyncEnd = _asyncLocalEnd.Value; // Save previous AsyncLocal value for nesting support + _testState.End = start + maxDiff; + _asyncLocalEnd.Value = start + maxDiff; // Set AsyncLocal for proper async propagation T ret = default; using (var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) @@ -320,6 +323,7 @@ public async Task WithinAsync( // Make sure we stop the delay task cts.Cancel(); _testState.End = prevEnd; + _asyncLocalEnd.Value = prevAsyncEnd; // Restore previous AsyncLocal value } } From 254a8bf132214df3a58c7d521f1bc54ee83c8dc5 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 29 Dec 2025 12:52:56 -0600 Subject: [PATCH 2/2] remove `static` --- src/core/Akka.TestKit/TestKitBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/Akka.TestKit/TestKitBase.cs b/src/core/Akka.TestKit/TestKitBase.cs index 0e95ac0f908..28df99051a9 100644 --- a/src/core/Akka.TestKit/TestKitBase.cs +++ b/src/core/Akka.TestKit/TestKitBase.cs @@ -30,7 +30,7 @@ public abstract partial class TestKitBase : IActorRefFactory { // AsyncLocal for proper timeout propagation across async boundaries. // This ensures WithinAsync timeout flows correctly to EventFilter and other async operations. - private static readonly AsyncLocal _asyncLocalEnd = new(); + private readonly AsyncLocal _asyncLocalEnd = new(); private class TestState {