From ee6a2e2a9f006b0f26e917b9a2ca48eacb3562a7 Mon Sep 17 00:00:00 2001 From: Rafael Vuijk Date: Sun, 25 Oct 2026 04:25:54 +0000 Subject: [PATCH] Wait for the cancelled tasks to stop before asserting how they ended TestThoseOnlyCancel cancelled a task at 750 ms and asserted at about 1,500 ms that it had ended as cancelled, so it asserted a stopping time. On ubuntu-latest the task was sometimes still Running, which is the failure in 12 of the last 40 failed C# Test runs, 8 of them on master. On a desktop the worst stop is 195 ms, 182 ms under coverlet, and 359 ms inside the full suite on four cores under coverlet. It also required 30 solves to take at least 3 s, and failed every row when they did not. They take 2.3 s cold on a desktop, so all 26 rows failed there. Each task now solves until it is cancelled. The test waits for the cancelled tasks to stop under a 30 s budget, then asserts that they are Canceled and that the rest are still Running. A solve that starts with its token already cancelled has to throw; if it returns, the task ends as RanToCompletion and the assertion fails, so a library that ignores the token fails the test rather than hanging it. Checked by breaking the library on purpose: ignoring the token fails every row that cancels something (20 of 26, all RanToCompletion); one token shared by every thread fails 18 (Faulted and RanToCompletion); noticing a cancel a second late passes, where master's version of the test fails with the CI message, Status: Running. Fixes https://github.com/asc-community/AngouriMath/issues/2118 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_012sonx8iAspMiwRwokT1Ura --- .../Multithreading/MultithreadingCancel.cs | 56 +++++++++---------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/Sources/Tests/UnitTests/Core/Multithreading/MultithreadingCancel.cs b/Sources/Tests/UnitTests/Core/Multithreading/MultithreadingCancel.cs index fb8537b78..1ae4dbef7 100644 --- a/Sources/Tests/UnitTests/Core/Multithreading/MultithreadingCancel.cs +++ b/Sources/Tests/UnitTests/Core/Multithreading/MultithreadingCancel.cs @@ -9,7 +9,6 @@ using Xunit; using AngouriMath; using AngouriMath.Extensions; -using HonkSharp.Laziness; using System.Threading.Tasks; using System.Threading; using System.Linq; @@ -19,29 +18,30 @@ namespace AngouriMath.Tests.Core.Multithreading [Trait("Area", "Core")] public sealed class MultithreadingCancel { - // Thirty solves rather than ten: warm, ten were a second and a tenth on a desktop - // against a cancellation at three quarters of a second. - private static void SomeLongLastingTask() + // Solves until it is cancelled, rather than a fixed number of times. A fixed workload put two + // clocks in this test, each wrong on some machine: it had to last three seconds, which thirty + // solves no longer do on a desktop, and a cancelled task had to stop within three quarters of + // a second, which on the Ubuntu runner it sometimes did not. A solve that starts with its token + // already cancelled has to throw. If it returns instead, the task ends as RanToCompletion and + // the assertion below says so, rather than the test running forever. + // https://github.com/asc-community/AngouriMath/issues/2118 + private static void SolveUntilCancelled(CancellationToken token) { - Entity t; - for (int i = 0; i < 30; i++) - t = - "b c d e f g a e cos(x ^ 4 + 3)1 + a f c d cos(x ^ 4 + 2)2 - k d cos(x ^ 4 + 3)2 + sin(x ^ 4 + 3) + e = 0" - .Solve("x"); + while (true) + { + var cancelledBefore = token.IsCancellationRequested; + "b c d e f g a e cos(x ^ 4 + 3)1 + a f c d cos(x ^ 4 + 2)2 - k d cos(x ^ 4 + 3)2 + sin(x ^ 4 + 3) + e = 0" + .Solve("x"); + if (cancelledBefore) + return; + } } - const int ShouldLastAtLeast = 3000; + const int CancelAt = 750; - /// - /// We are going to cancel a long-lasting task. If for some reason, it is not that long lasting, - /// this will become false - /// - public static bool MakesSenseToPerformTest => makesSenseToPerformTest.GetValue( - static @this => !new TimeOutChecker().BeingCompletedForLessThan( - SomeLongLastingTask - , ShouldLastAtLeast), someReference); - private static object someReference = new(); - private static LazyPropertyA makesSenseToPerformTest; + // How long a cancelled task may take to stop. How soon it stops is the runner's business; + // that it stops is the library's, so this is a bound no runner should reach. + const int StopBudget = 30_000; [Theory] [InlineData(false, true)] @@ -72,8 +72,6 @@ private static void SomeLongLastingTask() [InlineData(true, true, true, true, true)] public async void TestThoseOnlyCancel(bool generateChild, params bool[] threadToCancel) { - Assert.True(MakesSenseToPerformTest, $"The given task completed too soon, consider lowering the constant {ShouldLastAtLeast}"); - // Each task on a thread of its own rather than on the pool: the tasks are // processor-bound, and on a runner with two cores four of them hold every pool // thread, so the timer behind CancelAfter -- a pool work item queued behind @@ -92,8 +90,8 @@ static Task OnItsOwnThread(Action action, CancellationToken token) var task = OnItsOwnThread(() => { if (generateChild) - OnItsOwnThread(SomeLongLastingTask, token).GetAwaiter().GetResult(); - SomeLongLastingTask(); + OnItsOwnThread(() => SolveUntilCancelled(token), token).GetAwaiter().GetResult(); + SolveUntilCancelled(token); }, token); var res = (cts, task); return res; @@ -106,17 +104,19 @@ static Task OnItsOwnThread(Action action, CancellationToken token) { for (int i = 0; i < threadToCancel.Length; i++) if (threadToCancel[i]) - ctss[i].CancelAfter(ShouldLastAtLeast / 4); + ctss[i].CancelAfter(CancelAt); var waitAndSwallow = new WaiterAndSwallower(5); - await waitAndSwallow.Wait(ShouldLastAtLeast / 4); + await waitAndSwallow.Wait(CancelAt); await waitAndSwallow.WaitWhile(() => tasks.Any(c => c.Status == TaskStatus.WaitingToRun)); // wait while at least one task is waiting for execution start - await waitAndSwallow.Wait(ShouldLastAtLeast / 4); // wait some more time + await Task.WhenAny(Task.WhenAll(tasks.Where((_, i) => threadToCancel[i])), Task.Delay(StopBudget)); + await waitAndSwallow.Wait(CancelAt); // and as long again, for a task cancelled that was not meant to be } catch (OperationCanceledException) { } // we are going to check their states in finally finally { for (int i = 0; i < threadToCancel.Length; i++) - Assert.True(threadToCancel[i] == tasks[i].IsCanceled, $"Task number {i}: {threadToCancel[i]}, but instead {tasks[i].IsCanceled}. Status: {tasks[i].Status}"); + Assert.True(threadToCancel[i] ? tasks[i].IsCanceled : tasks[i].Status == TaskStatus.Running, + $"Task number {i} was {(threadToCancel[i] ? "" : "not ")}cancelled, and its status is {tasks[i].Status}"); foreach (var cts in ctss) cts.Cancel(); try