From 6ed1e1a368da836f4eba12502ac218a4bf67c066 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:49:22 +0000 Subject: [PATCH 1/3] Initial plan From f4072d2305231b93fa1f1446c5353fd2b57f752e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 19 Mar 2026 16:59:52 +0000 Subject: [PATCH 2/3] Fix non-deterministic branch coverage in HedgingExecutionContext hedging delay tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the single non-deterministic TryWaitForCompletedExecutionAsync_HedgedExecution_Ok test with two focused, deterministic tests: 1. DelayFiresFirst_ReturnsNull: primary task delay (1h) > hedging delay (5s). Advance only 10s to fire the hedging delay without completing the primary, so whenAnyHedgedTask.IsCompleted is deterministically false → returns null. 2. TaskCompletesBeforeDelay_ReturnsTask: primary task delay (5s) < hedging delay (1h). Advance only 10s to complete the primary without firing the hedging delay timer, so whenAnyHedgedTask.IsCompleted is deterministically true → returns non-null. Both tests assert the return value explicitly so mutation tests reliably fail when the condition at HedgingExecutionContext.cs:132 is inverted. Co-authored-by: martincostello <1439341+martincostello@users.noreply.github.com> --- .../HedgingExecutionContextTests.cs | 42 +++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs index 30fc383435c..0f17952b4ad 100644 --- a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs +++ b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs @@ -151,7 +151,7 @@ public async Task TryWaitForCompletedExecutionAsync_SynchronousExecution_Ok() [InlineData(false)] [InlineData(true)] [Theory] - public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_Ok(bool continueOnCapturedContext) + public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_DelayFiresFirst_ReturnsNull(bool continueOnCapturedContext) { _resilienceContext.ContinueOnCapturedContext = continueOnCapturedContext; var context = Create(); @@ -171,12 +171,48 @@ public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_Ok(bool cont #pragma warning restore xUnit1031 // Do not use blocking task operations in test method _timeProvider.TimerEntries.Count.ShouldBe(count + 1); _timeProvider.TimerEntries.Last().Delay.ShouldBe(hedgingDelay); - _timeProvider.Advance(TimeSpan.FromDays(1)); - await task; + + // Advance only past the hedging delay (5 s) but NOT past the primary task delay (1 h). + // This ensures the hedging delay fires first while the primary task is still running, + // so whenAnyHedgedTask.IsCompleted is deterministically false and null is returned. + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + var result = await task; + result.ShouldBeNull(); + + // Advance past the primary task delay for cleanup. + _timeProvider.Advance(TimeSpan.FromHours(2)); await context.Tasks[0].ExecutionTaskSafe!; context.Tasks[0].AcceptOutcome(); } + [InlineData(false)] + [InlineData(true)] + [Theory] + public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_TaskCompletesBeforeDelay_ReturnsTask(bool continueOnCapturedContext) + { + _resilienceContext.ContinueOnCapturedContext = continueOnCapturedContext; + var context = Create(); + context.Initialize(_resilienceContext); + + // Primary task delay (5 s) is shorter than the hedging delay (1 h). + await LoadExecutionAsync(context, TimeSpan.FromSeconds(5)); + + var hedgingDelay = TimeSpan.FromHours(1); + var task = context.TryWaitForCompletedExecutionAsync(hedgingDelay).AsTask(); +#pragma warning disable xUnit1031 // Do not use blocking task operations in test method + task.Wait(20, TestCancellation.Token).ShouldBeFalse(); +#pragma warning restore xUnit1031 // Do not use blocking task operations in test method + + // Advance only past the primary task delay (5 s) but NOT past the hedging delay (1 h). + // This ensures the primary task completes first while the hedging delay timer is still + // pending, so whenAnyHedgedTask.IsCompleted is deterministically true and the completed + // task is returned (non-null). + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + var result = await task; + result.ShouldNotBeNull(); + result!.AcceptOutcome(); + } + [Fact] public async Task TryWaitForCompletedExecutionAsync_TwiceWhenSecondaryGeneratorNotRegistered_Ok() { From 04043fe396874c9b748bf82456e807620a814f91 Mon Sep 17 00:00:00 2001 From: martincostello Date: Thu, 19 Mar 2026 18:02:36 +0000 Subject: [PATCH 3/3] Test refactoring Refactoring to make the tests a bit more readable and a little less repetitive. --- .../HedgingExecutionContextTests.cs | 134 +++++++++++++----- 1 file changed, 102 insertions(+), 32 deletions(-) diff --git a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs index 0f17952b4ad..078a0979483 100644 --- a/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs +++ b/test/Polly.Core.Tests/Hedging/Controller/HedgingExecutionContextTests.cs @@ -26,13 +26,16 @@ public HedgingExecutionContextTests() { _timeProvider = new HedgingTimeProvider(); _cts = new CancellationTokenSource(); - _hedgingHandler = HedgingHelper.CreateHandler(outcome => outcome switch - { - { Exception: ApplicationException } => true, - { Result: DisposableResult result } when result.Name == Handled => true, - _ => false - }, - args => Generator(args)); + + _hedgingHandler = HedgingHelper.CreateHandler( + outcome => outcome switch + { + { Exception: ApplicationException } => true, + { Result: DisposableResult result } when result.Name == Handled => true, + _ => false + }, + args => Generator(args)); + _resilienceContext = ResilienceContextPool.Shared.Get(_cts.Token).Initialize(false); _resilienceContext.Properties.Set(_myKey, "dummy"); @@ -70,17 +73,16 @@ public void Initialize_Ok() context.IsInitialized.ShouldBeTrue(); } + [Theory] [InlineData(0)] [InlineData(1)] [InlineData(-1)] - [Theory] public async Task TryWaitForCompletedExecutionAsync_Initialized_Ok(int delay) { var context = Create(); context.Initialize(_resilienceContext); - var delayTimeSpan = TimeSpan.FromSeconds(delay); - var task = context.TryWaitForCompletedExecutionAsync(delayTimeSpan); + var task = context.TryWaitForCompletedExecutionAsync(TimeSpan.FromSeconds(delay)); _timeProvider.Advance(TimeSpan.FromHours(1)); @@ -92,6 +94,7 @@ public async Task TryWaitForCompletedExecutionAsync_FinishedTask_Ok() { var context = Create(); context.Initialize(_resilienceContext); + await context.LoadExecutionAsync((_, _) => Outcome.FromResultAsValueTask(new DisposableResult("dummy")), "state"); var task = await context.TryWaitForCompletedExecutionAsync(TimeSpan.Zero); @@ -109,11 +112,14 @@ public async Task TryWaitForCompletedExecutionAsync_ConcurrentExecution_Ok() { var context = Create(); context.Initialize(_resilienceContext); - ConfigureSecondaryTasks(TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + + var oneHour = TimeSpan.FromHours(1); + + ConfigureSecondaryTasks(oneHour, oneHour); for (int i = 0; i < _maxAttempts - 1; i++) { - await LoadExecutionAsync(context, TimeSpan.FromHours(1)); + await LoadExecutionAsync(context, oneHour); } for (int i = 0; i < _maxAttempts; i++) @@ -122,7 +128,11 @@ public async Task TryWaitForCompletedExecutionAsync_ConcurrentExecution_Ok() } _timeProvider.Advance(TimeSpan.FromDays(1)); + await context.TryWaitForCompletedExecutionAsync(TimeSpan.Zero); + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); + await context.Tasks[0].ExecutionTaskSafe!; context.Tasks[0].AcceptOutcome(); } @@ -132,43 +142,57 @@ public async Task TryWaitForCompletedExecutionAsync_SynchronousExecution_Ok() { var context = Create(); context.Initialize(_resilienceContext); - ConfigureSecondaryTasks(TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + + var oneHour = TimeSpan.FromHours(1); + + ConfigureSecondaryTasks(oneHour, oneHour); for (int i = 0; i < _maxAttempts - 1; i++) { - await LoadExecutionAsync(context, TimeSpan.FromHours(1)); + await LoadExecutionAsync(context, oneHour); } var task = context.TryWaitForCompletedExecutionAsync(System.Threading.Timeout.InfiniteTimeSpan).AsTask(); + #pragma warning disable xUnit1031 // Do not use blocking task operations in test method task.Wait(20, TestCancellation.Token).ShouldBeFalse(); #pragma warning restore xUnit1031 // Do not use blocking task operations in test method + _timeProvider.Advance(TimeSpan.FromDays(1)); + await task; + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); context.Tasks[0].AcceptOutcome(); } + [Theory] [InlineData(false)] [InlineData(true)] - [Theory] public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_DelayFiresFirst_ReturnsNull(bool continueOnCapturedContext) { _resilienceContext.ContinueOnCapturedContext = continueOnCapturedContext; + + var oneHour = TimeSpan.FromHours(1); + var context = Create(); context.Initialize(_resilienceContext); - ConfigureSecondaryTasks(TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + + ConfigureSecondaryTasks(oneHour, oneHour); for (int i = 0; i < _maxAttempts - 1; i++) { - await LoadExecutionAsync(context, TimeSpan.FromHours(1)); + await LoadExecutionAsync(context, oneHour); } var hedgingDelay = TimeSpan.FromSeconds(5); var count = _timeProvider.TimerEntries.Count; var task = context.TryWaitForCompletedExecutionAsync(hedgingDelay).AsTask(); + #pragma warning disable xUnit1031 // Do not use blocking task operations in test method task.Wait(20, TestCancellation.Token).ShouldBeFalse(); #pragma warning restore xUnit1031 // Do not use blocking task operations in test method + _timeProvider.TimerEntries.Count.ShouldBe(count + 1); _timeProvider.TimerEntries.Last().Delay.ShouldBe(hedgingDelay); @@ -176,21 +200,27 @@ public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_DelayFiresFi // This ensures the hedging delay fires first while the primary task is still running, // so whenAnyHedgedTask.IsCompleted is deterministically false and null is returned. _timeProvider.Advance(TimeSpan.FromSeconds(10)); + var result = await task; result.ShouldBeNull(); // Advance past the primary task delay for cleanup. _timeProvider.Advance(TimeSpan.FromHours(2)); + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); + await context.Tasks[0].ExecutionTaskSafe!; + context.Tasks[0].AcceptOutcome(); } + [Theory] [InlineData(false)] [InlineData(true)] - [Theory] public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_TaskCompletesBeforeDelay_ReturnsTask(bool continueOnCapturedContext) { _resilienceContext.ContinueOnCapturedContext = continueOnCapturedContext; + var context = Create(); context.Initialize(_resilienceContext); @@ -199,6 +229,7 @@ public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_TaskComplete var hedgingDelay = TimeSpan.FromHours(1); var task = context.TryWaitForCompletedExecutionAsync(hedgingDelay).AsTask(); + #pragma warning disable xUnit1031 // Do not use blocking task operations in test method task.Wait(20, TestCancellation.Token).ShouldBeFalse(); #pragma warning restore xUnit1031 // Do not use blocking task operations in test method @@ -208,7 +239,9 @@ public async Task TryWaitForCompletedExecutionAsync_HedgedExecution_TaskComplete // pending, so whenAnyHedgedTask.IsCompleted is deterministically true and the completed // task is returned (non-null). _timeProvider.Advance(TimeSpan.FromSeconds(10)); + var result = await task; + result.ShouldNotBeNull(); result!.AcceptOutcome(); } @@ -220,6 +253,7 @@ public async Task TryWaitForCompletedExecutionAsync_TwiceWhenSecondaryGeneratorN var context = Create(); context.Initialize(_resilienceContext); + await context.LoadExecutionAsync((_, _) => Outcome.FromResultAsValueTask(new DisposableResult("dummy")), "state"); await context.LoadExecutionAsync((_, _) => Outcome.FromResultAsValueTask(new DisposableResult("dummy")), "state"); @@ -234,15 +268,20 @@ public async Task TryWaitForCompletedExecutionAsync_TwiceWhenSecondaryGeneratorR { var context = Create(); context.Initialize(_resilienceContext); + await LoadExecutionAsync(context); await LoadExecutionAsync(context); Generator = args => () => Outcome.FromResultAsValueTask(new DisposableResult { Name = "secondary" }); var task = await context.TryWaitForCompletedExecutionAsync(TimeSpan.Zero); + task!.Type.ShouldBe(HedgedTaskType.Primary); task!.AcceptOutcome(); + context.LoadedTasks.ShouldBe(2); + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(2); context.Tasks[0].Type.ShouldBe(HedgedTaskType.Primary); context.Tasks[1].Type.ShouldBe(HedgedTaskType.Secondary); } @@ -251,9 +290,13 @@ public async Task TryWaitForCompletedExecutionAsync_TwiceWhenSecondaryGeneratorR public async Task LoadExecutionAsync_MaxTasks_NoMoreTasksAdded() { _maxAttempts = 3; + var context = Create(); context.Initialize(_resilienceContext); - ConfigureSecondaryTasks(TimeSpan.FromHours(1), TimeSpan.FromHours(1), TimeSpan.FromHours(1), TimeSpan.FromHours(1)); + + var oneHour = TimeSpan.FromHours(1); + + ConfigureSecondaryTasks(oneHour, oneHour, oneHour, oneHour); for (int i = 0; i < _maxAttempts; i++) { @@ -263,7 +306,10 @@ public async Task LoadExecutionAsync_MaxTasks_NoMoreTasksAdded() (await LoadExecutionAsync(context)).Loaded.ShouldBeFalse(); context.LoadedTasks.ShouldBe(_maxAttempts); + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); context.Tasks[0].AcceptOutcome(); + _returnedExecutions.ShouldBeEmpty(); } @@ -271,9 +317,11 @@ public async Task LoadExecutionAsync_MaxTasks_NoMoreTasksAdded() public async Task LoadExecutionAsync_EnsureCorrectAttemptNumber() { var attempt = -1; + var context = Create(); context.Initialize(_resilienceContext); - Generator = args => + + Generator = (args) => { attempt = args.AttemptNumber; return null; @@ -286,14 +334,16 @@ public async Task LoadExecutionAsync_EnsureCorrectAttemptNumber() attempt.ShouldBe(1); } + [Theory] [InlineData(true)] [InlineData(false)] - [Theory] public async Task LoadExecutionAsync_NoMoreSecondaryTasks_AcceptFinishedOutcome(bool allExecuted) { _maxAttempts = 4; + var context = Create(); context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(allExecuted ? TimeSpan.Zero : TimeSpan.FromHours(1)); // primary @@ -313,24 +363,29 @@ public async Task LoadExecutionAsync_NoMoreSecondaryTasks_AcceptFinishedOutcome( pair.Loaded.ShouldBeFalse(); _returnedExecutions.Count.ShouldBe(1); + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); + + var task = context.Tasks[0]; + if (allExecuted) { pair.Outcome.ShouldNotBeNull(); - context.Tasks[0].IsAccepted.ShouldBeTrue(); + task.IsAccepted.ShouldBeTrue(); } else { pair.Outcome.ShouldBeNull(); - context.Tasks[0].IsAccepted.ShouldBeFalse(); + task.IsAccepted.ShouldBeFalse(); } - context.Tasks[0].AcceptOutcome(); + task.AcceptOutcome(); } [Fact] public async Task LoadExecution_NoMoreTasks_Throws() { _maxAttempts = 0; + var context = Create(); context.Initialize(_resilienceContext); @@ -344,11 +399,16 @@ public async Task Complete_EnsureOriginalContextPreparedWithAcceptedOutcome(bool { // arrange var type = primary ? HedgedTaskType.Primary : HedgedTaskType.Secondary; + var expectedCount = primary ? 1 : 2; + var context = Create(); var originalProps = _resilienceContext.Properties; context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(TimeSpan.Zero); + await ExecuteAllTasksAsync(context, 2); + context.Tasks.First(v => v.Type == type).AcceptOutcome(); // act @@ -356,23 +416,19 @@ public async Task Complete_EnsureOriginalContextPreparedWithAcceptedOutcome(bool // assert _resilienceContext.Properties.ShouldBeSameAs(originalProps); - if (primary) - { - _resilienceContext.Properties.Options.Count.ShouldBe(1); - } - else - { - _resilienceContext.Properties.Options.Count.ShouldBe(2); - } + _resilienceContext.Properties.Options.Count.ShouldBe(expectedCount); } [Fact] public async Task Complete_NoTasks_EnsureCleaned() { var props = _resilienceContext.Properties; + var context = Create(); context.Initialize(_resilienceContext); + await context.DisposeAsync(); + _resilienceContext.Properties.ShouldBeSameAs(props); } @@ -381,7 +437,9 @@ public async Task Complete_NoAcceptedTasks_ShouldNotThrow() { var context = Create(); context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(TimeSpan.Zero); + await ExecuteAllTasksAsync(context, 2); Should.NotThrow(() => context.DisposeAsync().AsTask().Wait()); @@ -392,8 +450,12 @@ public async Task Complete_MultipleAcceptedTasks_ShouldNotThrow() { var context = Create(); context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(TimeSpan.Zero); + await ExecuteAllTasksAsync(context, 2); + + context.Tasks.Count.ShouldBe(2); context.Tasks[0].AcceptOutcome(); context.Tasks[1].AcceptOutcome(); @@ -408,7 +470,9 @@ public async Task Complete_EnsurePendingTasksCleaned() var context = Create(); context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(TimeSpan.FromHours(1)); + (await LoadExecutionAsync(context)).Execution!.OnReset = (execution) => { execution.Outcome.Result.ShouldBeOfType(); @@ -424,6 +488,7 @@ public async Task Complete_EnsurePendingTasksCleaned() await context.TryWaitForCompletedExecutionAsync(System.Threading.Timeout.InfiniteTimeSpan); var pending = context.Tasks[1].ExecutionTaskSafe!; + #pragma warning disable xUnit1031 // Do not use blocking task operations in test method pending.Wait(10, TestCancellation.Token).ShouldBeFalse(); #pragma warning restore xUnit1031 // Do not use blocking task operations in test method @@ -442,8 +507,12 @@ public async Task Complete_EnsureCleaned() { var context = Create(); context.Initialize(_resilienceContext); + ConfigureSecondaryTasks(TimeSpan.Zero); + await ExecuteAllTasksAsync(context, 2); + + context.Tasks.Count.ShouldBeGreaterThanOrEqualTo(1); context.Tasks[0].AcceptOutcome(); await context.DisposeAsync(); @@ -452,6 +521,7 @@ public async Task Complete_EnsureCleaned() context.PrimaryContext!.ShouldBeNull(); _onReset.WaitOne(AssertTimeout); + _resets.Count.ShouldBe(1); _returnedExecutions.Count.ShouldBe(2); }