From 40858c78d3f5336f5f3812ad684a3f3fd2f2034f Mon Sep 17 00:00:00 2001 From: Michal Nebes <16987208+mnebes@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:30:36 +0200 Subject: [PATCH 1/5] Reproduce the race condition in the WorkScheduler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test creates a controlled version of the narrow race window. It does not try to corrupt a dictionary; it checks the earlier condition that makes that possible: the scheduler reports the request complete while a resolver is still finishing WorkScheduler.Complete. 1. Keep one resolver pending. The test’s Query.GetValueAsync returns a TaskCompletionSource task. It waits for the diagnostic listener’s first StopProcessing notification, which means the scheduler has run out of immediately available work and paused. 2. Release the resolver, then stop it at the critical point. The test completes the resolver’s task. A DEBUG-only hook in WorkScheduler.Complete runs just after work.Complete() has reduced the running-task count to zero, but before _completed and _activePaths are updated under _sync. The hook signals entered and waits on release, holding the resolver precisely in that gap. 3. Wake the scheduler. The test calls scheduler.OnNext with a batch-dispatch event. This simulates a scheduler wake-up that can occur while the resolver is in that gap. The listener’s second StartProcessing signal confirms the scheduler actually resumed. 4. Check which event happens first. Task.WhenAny watches for the GraphQL execution to finish, the scheduler to pause again, or a safety timeout. The finally block releases the resolver regardless of the assertion outcome, preventing a stuck test. The hook and test are compiled only in DEBUG builds. --- .../Execution/Processing/WorkScheduler.cs | 11 ++ .../WorkSchedulerCompletionTests.cs | 104 ++++++++++++++++++ 2 files changed, 115 insertions(+) create mode 100644 src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs index 6506e8f4092..1ab555d07a1 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs @@ -7,6 +7,10 @@ namespace HotChocolate.Execution.Processing; /// internal sealed partial class WorkScheduler { +#if DEBUG + internal const string LastResolverCompletionHookKey = "WorkScheduler.LastResolverCompletionHook"; +#endif + private readonly Dictionary _activeBranches = []; /// @@ -108,6 +112,13 @@ public void Complete(IExecutionTask task) if (work.Complete()) { +#if DEBUG + if (_requestContext.ContextData.TryGetValue(LastResolverCompletionHookKey, out var value) + && value is Action hook) + { + hook(this); + } +#endif lock (_sync) { _completed.Add(resolverTask.Id); diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs new file mode 100644 index 00000000000..3c7a6b8bdd8 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs @@ -0,0 +1,104 @@ +#if DEBUG +using HotChocolate.Execution.Instrumentation; +using HotChocolate.Fetching; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution.Processing; + +public class WorkSchedulerCompletionTests +{ + [Fact] + public async Task ExecuteAsync_Should_WaitForCompletionBookkeeping_When_LastResolverStopsRunning() + { + // arrange + var entered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resumed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var pausedAgain = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resolverGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var release = new ManualResetEventSlim(); + var cancellationToken = TestContext.Current.CancellationToken; + var services = new ServiceCollection(); + services.AddSingleton(resolverGate); + var executor = await services + .AddGraphQL() + .AddQueryType() + .AddDiagnosticEventListener(_ => new CompletionHookListener( + stopped, + resumed, + pausedAgain, + scheduler => + { + entered.TrySetResult(scheduler); + release.Wait(TimeSpan.FromSeconds(10), cancellationToken); + })) + .BuildRequestExecutorAsync(cancellationToken: cancellationToken); + + // act + var execution = executor.ExecuteAsync("{ value }", cancellationToken); + try + { + await stopped.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + resolverGate.SetResult(42); + var scheduler = await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + scheduler.OnNext(new BatchDispatchEventArgs(BatchDispatchEventType.Enqueued)); + await resumed.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + var first = await Task.WhenAny( + execution, + pausedAgain.Task, + Task.Delay(TimeSpan.FromSeconds(1), cancellationToken)); + + // assert + Assert.NotSame(execution, first); + } + finally + { + release.Set(); + await using var result = await execution.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); + } + } + + public sealed class Query + { + public Task GetValueAsync([Service] TaskCompletionSource resolverGate) + => resolverGate.Task; + } + + private sealed class CompletionHookListener( + TaskCompletionSource stopped, + TaskCompletionSource resumed, + TaskCompletionSource pausedAgain, + Action hook) + : ExecutionDiagnosticEventListener + { + private int _processingPasses; + + public override IDisposable ExecuteRequest(RequestContext context) + { + context.ContextData[WorkScheduler.LastResolverCompletionHookKey] = hook; + return EmptyScope; + } + + public override void StartProcessing(RequestContext context) + { + if (Interlocked.Increment(ref _processingPasses) == 2) + { + resumed.TrySetResult(); + } + } + + public override void StopProcessing(RequestContext context) + { + if (_processingPasses == 1) + { + stopped.TrySetResult(); + } + else + { + pausedAgain.TrySetResult(); + } + } + } +} +#endif From c7ca161d260055a1f8956bdfa9e05b4c6578dcf9 Mon Sep 17 00:00:00 2001 From: Michal Nebes <16987208+mnebes@users.noreply.github.com> Date: Thu, 1 Oct 2026 14:38:37 +0200 Subject: [PATCH 2/5] Fix work scheduler completion bookkeeping race This moves the reporting of task completion into a lock to avoid stale state in a very specific set of conditions. --- .../Execution/Processing/WorkScheduler.cs | 46 ++++++------------- 1 file changed, 14 insertions(+), 32 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs index 1ab555d07a1..5c93bde041e 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs @@ -108,19 +108,19 @@ public void Complete(IExecutionTask task) switch (task) { case Tasks.ResolverTask resolverTask: - CompleteBranchTask(task.BranchId); - - if (work.Complete()) + lock (_sync) { -#if DEBUG - if (_requestContext.ContextData.TryGetValue(LastResolverCompletionHookKey, out var value) - && value is Action hook) + CompleteBranchTaskUnsafe(task.BranchId); + + if (work.Complete()) { - hook(this); - } +#if DEBUG + if (_requestContext.ContextData.TryGetValue(LastResolverCompletionHookKey, out var value) + && value is Action hook) + { + hook(this); + } #endif - lock (_sync) - { _completed.Add(resolverTask.Id); DecrementPathCountUnsafe(resolverTask.FieldSelectionPath); } @@ -144,11 +144,11 @@ public void Complete(IExecutionTask task) break; default: - CompleteBranchTask(task.BranchId); - - if (work.Complete()) + lock (_sync) { - lock (_sync) + CompleteBranchTaskUnsafe(task.BranchId); + + if (work.Complete()) { _completed.Add(task.Id); } @@ -175,24 +175,6 @@ private void RegisterBranchTaskUnsafe(int branchId) branch.RegisterTask(); } - private void CompleteBranchTask(int branchId) - { - if (branchId == BranchTracker.SystemBranchId) - { - return; - } - - lock (_sync) - { - if (_activeBranches.TryGetValue(branchId, out var branch) - && branch.CompleteTask()) - { - _activeBranches.Remove(branchId); - branch.Complete(); - } - } - } - [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CompleteBranchTaskUnsafe(int branchId) { From 09c9f390ee1d42e643e28ed407ea9bc708d933a9 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Thu, 8 Oct 2026 13:34:14 -0700 Subject: [PATCH 3/5] Replace the WorkScheduler completion hook with lock-ownership asserts --- .../Processing/WorkScheduler.Batching.cs | 8 ++ .../Processing/WorkScheduler.Execute.cs | 2 + .../Execution/Processing/WorkScheduler.cs | 38 ++++--- .../WorkSchedulerCompletionTests.cs | 104 ------------------ 4 files changed, 34 insertions(+), 118 deletions(-) delete mode 100644 src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs index 64d3ca71017..c0e14f96467 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Batching.cs @@ -50,6 +50,8 @@ public void RegisterBatchEntry( [MethodImpl(MethodImplOptions.AggressiveInlining)] private void IncrementPathCountUnsafe(SelectionPath path) { + AssertLockHeld(); + ref var count = ref CollectionsMarshal.GetValueRefOrAddDefault(_activePaths, path, out _); count++; } @@ -60,6 +62,8 @@ private void IncrementPathCountUnsafe(SelectionPath path) /// private void DecrementPathCountUnsafe(SelectionPath path) { + AssertLockHeld(); + ref var count = ref CollectionsMarshal.GetValueRefOrNullRef(_activePaths, path); if (!Unsafe.IsNullRef(ref count) && --count <= 0) @@ -75,6 +79,8 @@ private void DecrementPathCountUnsafe(SelectionPath path) /// private void TryDispatchPendingBatchesUnsafe() { + AssertLockHeld(); + if (_pendingBatches.Count == 0) { return; @@ -117,6 +123,8 @@ private void TryDispatchPendingBatchesUnsafe() /// private bool CanDispatchBatchUnsafe(SelectionPath batchPath) { + AssertLockHeld(); + // Walk up ancestor paths. If any ancestor still has active tasks, // more entries could still be added to this batch. var ancestor = batchPath.Parent; diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs index 3f471aea09f..0cbd7c59000 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.Execute.cs @@ -208,6 +208,8 @@ private void TryDispatchOrComplete() private void TryDispatchOrCompleteUnsafe(bool isWaitingForTaskCompletion) { + AssertLockHeld(); + if (_isCompleted) { return; diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs index 5c93bde041e..0de4506318c 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkScheduler.cs @@ -1,3 +1,4 @@ +using System.Diagnostics; using System.Runtime.CompilerServices; namespace HotChocolate.Execution.Processing; @@ -7,10 +8,6 @@ namespace HotChocolate.Execution.Processing; /// internal sealed partial class WorkScheduler { -#if DEBUG - internal const string LastResolverCompletionHookKey = "WorkScheduler.LastResolverCompletionHook"; -#endif - private readonly Dictionary _activeBranches = []; /// @@ -112,15 +109,8 @@ public void Complete(IExecutionTask task) { CompleteBranchTaskUnsafe(task.BranchId); - if (work.Complete()) + if (CompleteWorkUnsafe(work)) { -#if DEBUG - if (_requestContext.ContextData.TryGetValue(LastResolverCompletionHookKey, out var value) - && value is Action hook) - { - hook(this); - } -#endif _completed.Add(resolverTask.Id); DecrementPathCountUnsafe(resolverTask.FieldSelectionPath); } @@ -135,7 +125,7 @@ public void Complete(IExecutionTask task) CompleteBranchTaskUnsafe(additionalBranchId); } - if (work.Complete()) + if (CompleteWorkUnsafe(work)) { _completed.Add(task.Id); DecrementPathCountUnsafe(batchResolverTask.FieldSelectionPath); @@ -148,7 +138,7 @@ public void Complete(IExecutionTask task) { CompleteBranchTaskUnsafe(task.BranchId); - if (work.Complete()) + if (CompleteWorkUnsafe(work)) { _completed.Add(task.Id); } @@ -159,8 +149,26 @@ public void Complete(IExecutionTask task) _signal.Set(); } + private bool CompleteWorkUnsafe(WorkQueue work) + { + AssertLockHeld(); + return work.Complete(); + } + + [Conditional("DEBUG")] + private void AssertLockHeld() + { +#if NET9_0_OR_GREATER + Debug.Assert(_sync.IsHeldByCurrentThread); +#else + Debug.Assert(Monitor.IsEntered(_sync)); +#endif + } + private void RegisterBranchTaskUnsafe(int branchId) { + AssertLockHeld(); + if (branchId == BranchTracker.SystemBranchId) { return; @@ -178,6 +186,8 @@ private void RegisterBranchTaskUnsafe(int branchId) [MethodImpl(MethodImplOptions.AggressiveInlining)] private void CompleteBranchTaskUnsafe(int branchId) { + AssertLockHeld(); + if (branchId == BranchTracker.SystemBranchId) { return; diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs deleted file mode 100644 index 3c7a6b8bdd8..00000000000 --- a/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerCompletionTests.cs +++ /dev/null @@ -1,104 +0,0 @@ -#if DEBUG -using HotChocolate.Execution.Instrumentation; -using HotChocolate.Fetching; -using Microsoft.Extensions.DependencyInjection; - -namespace HotChocolate.Execution.Processing; - -public class WorkSchedulerCompletionTests -{ - [Fact] - public async Task ExecuteAsync_Should_WaitForCompletionBookkeeping_When_LastResolverStopsRunning() - { - // arrange - var entered = new TaskCompletionSource( - TaskCreationOptions.RunContinuationsAsynchronously); - var stopped = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var resumed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var pausedAgain = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - var resolverGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using var release = new ManualResetEventSlim(); - var cancellationToken = TestContext.Current.CancellationToken; - var services = new ServiceCollection(); - services.AddSingleton(resolverGate); - var executor = await services - .AddGraphQL() - .AddQueryType() - .AddDiagnosticEventListener(_ => new CompletionHookListener( - stopped, - resumed, - pausedAgain, - scheduler => - { - entered.TrySetResult(scheduler); - release.Wait(TimeSpan.FromSeconds(10), cancellationToken); - })) - .BuildRequestExecutorAsync(cancellationToken: cancellationToken); - - // act - var execution = executor.ExecuteAsync("{ value }", cancellationToken); - try - { - await stopped.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - resolverGate.SetResult(42); - var scheduler = await entered.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - scheduler.OnNext(new BatchDispatchEventArgs(BatchDispatchEventType.Enqueued)); - await resumed.Task.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - var first = await Task.WhenAny( - execution, - pausedAgain.Task, - Task.Delay(TimeSpan.FromSeconds(1), cancellationToken)); - - // assert - Assert.NotSame(execution, first); - } - finally - { - release.Set(); - await using var result = await execution.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken); - } - } - - public sealed class Query - { - public Task GetValueAsync([Service] TaskCompletionSource resolverGate) - => resolverGate.Task; - } - - private sealed class CompletionHookListener( - TaskCompletionSource stopped, - TaskCompletionSource resumed, - TaskCompletionSource pausedAgain, - Action hook) - : ExecutionDiagnosticEventListener - { - private int _processingPasses; - - public override IDisposable ExecuteRequest(RequestContext context) - { - context.ContextData[WorkScheduler.LastResolverCompletionHookKey] = hook; - return EmptyScope; - } - - public override void StartProcessing(RequestContext context) - { - if (Interlocked.Increment(ref _processingPasses) == 2) - { - resumed.TrySetResult(); - } - } - - public override void StopProcessing(RequestContext context) - { - if (_processingPasses == 1) - { - stopped.TrySetResult(); - } - else - { - pausedAgain.TrySetResult(); - } - } - } -} -#endif From c3ceadc7c26c0667dd4f6964798066dcf1c53287 Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Thu, 8 Oct 2026 14:20:34 -0700 Subject: [PATCH 4/5] Add an opt-in stress test for the WorkScheduler completion race --- .../Processing/WorkSchedulerStressTests.cs | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerStressTests.cs diff --git a/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerStressTests.cs b/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerStressTests.cs new file mode 100644 index 00000000000..7b9365ab329 --- /dev/null +++ b/src/HotChocolate/Core/test/Execution.Tests/Processing/WorkSchedulerStressTests.cs @@ -0,0 +1,183 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using GreenDonut; +using Microsoft.Extensions.DependencyInjection; + +namespace HotChocolate.Execution.Processing; + +// Opt-in stress test for the completion bookkeeping of the WorkScheduler. It only runs when +// HC_STRESS_TESTS=1 is set; HC_STRESS_SECONDS overrides the default duration of 20 seconds. +// It lives in its own non-parallel collection because it observes process-wide unobserved task +// exceptions, which concurrently running tests could otherwise contribute to. +[Collection(WorkSchedulerStressCollection.Name)] +public class WorkSchedulerStressTests(ITestOutputHelper output) +{ + private const string EnableVariable = "HC_STRESS_TESTS"; + private const string SecondsVariable = "HC_STRESS_SECONDS"; + private const int DefaultSeconds = 20; + private const int Concurrency = 64; + private static readonly TimeSpan s_requestTimeout = TimeSpan.FromSeconds(30); + + private const string Query = + """ + { + items(count: 16) { + id + name + related { id name } + child { id name related { id } } + } + } + """; + + [Fact] + public async Task Execute_Should_NotProduceUnobservedExceptions_When_RequestsRunConcurrently() + { + // arrange + if (Environment.GetEnvironmentVariable(EnableVariable) != "1") + { + Assert.Skip($"Set {EnableVariable}=1 to run the WorkScheduler stress test."); + } + + var duration = TimeSpan.FromSeconds( + int.TryParse(Environment.GetEnvironmentVariable(SecondsVariable), out var seconds) && seconds > 0 + ? seconds + : DefaultSeconds); + var cancellationToken = TestContext.Current.CancellationToken; + + var unobserved = new ConcurrentQueue(); + var failures = new ConcurrentQueue(); + + void OnUnobservedException(object? sender, UnobservedTaskExceptionEventArgs args) + { + foreach (var exception in args.Exception.InnerExceptions) + { + unobserved.Enqueue(exception.ToString()); + } + + args.SetObserved(); + } + + var executor = await new ServiceCollection() + .AddGraphQL() + .AddQueryType() + .AddDataLoader() + .BuildRequestExecutorAsync(cancellationToken: cancellationToken); + + long requests = 0; + TaskScheduler.UnobservedTaskException += OnUnobservedException; + + try + { + // act + var stopwatch = Stopwatch.StartNew(); + + while (stopwatch.Elapsed < duration) + { + var round = new Task[Concurrency]; + + for (var i = 0; i < Concurrency; i++) + { + round[i] = Task.Run( + () => ExecuteRequestAsync(executor, failures, cancellationToken), + cancellationToken); + } + + await Task.WhenAll(round); + requests += Concurrency; + + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + } + finally + { + TaskScheduler.UnobservedTaskException -= OnUnobservedException; + } + + // assert + output.WriteLine($"requests={requests} failures={failures.Count} unobserved={unobserved.Count}"); + + Assert.True(requests > 0); + Assert.Empty(failures); + Assert.Empty(unobserved); + } + + private static async Task ExecuteRequestAsync( + IRequestExecutor executor, + ConcurrentQueue failures, + CancellationToken cancellationToken) + { + try + { + using var requestCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestCts.CancelAfter(s_requestTimeout); + + // The extra WaitAsync bounds a request that stops observing its cancellation token. + await using var result = await executor + .ExecuteAsync(Query, requestCts.Token) + .WaitAsync(s_requestTimeout + TimeSpan.FromSeconds(5), cancellationToken); + + if (result.ExpectOperationResult().Errors is { Count: > 0 } errors) + { + failures.Enqueue(string.Join(" | ", errors.Select(e => e.Message))); + } + } + catch (Exception ex) when (!cancellationToken.IsCancellationRequested) + { + failures.Enqueue(ex.ToString()); + } + } + + public sealed class StressQuery + { + public IEnumerable GetItems(int count) + { + for (var i = 0; i < count; i++) + { + yield return new StressItem(i); + } + } + } + + public sealed class StressItem(int id) + { + public int Id => id; + + public async Task GetNameAsync() + { + await Task.Yield(); + return "item-" + id; + } + + public async Task GetChildAsync() + { + await Task.Yield(); + return new StressItem(id * 1000); + } + + public async Task GetRelatedAsync( + StressRelatedDataLoader dataLoader, + CancellationToken cancellationToken) + => (await dataLoader.LoadAsync(id, cancellationToken))!; + } + + public sealed class StressRelatedDataLoader(IBatchScheduler batchScheduler, DataLoaderOptions options) + : BatchDataLoader(batchScheduler, options) + { + protected override async Task> LoadBatchAsync( + IReadOnlyList keys, + CancellationToken cancellationToken) + { + await Task.Yield(); + return keys.ToDictionary(k => k, k => new StressItem(k + 100000)); + } + } +} + +[CollectionDefinition(Name, DisableParallelization = true)] +public sealed class WorkSchedulerStressCollection +{ + public const string Name = "WorkSchedulerStress"; +} From ee70e190314d6d6f58efeb1d720e517e9a24f98f Mon Sep 17 00:00:00 2001 From: Michael Staib Date: Thu, 8 Oct 2026 14:30:11 -0700 Subject: [PATCH 5/5] Use plain counters in WorkQueue and document the scheduler lock requirement --- .../Types/Execution/Processing/WorkQueue.cs | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkQueue.cs b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkQueue.cs index 194bbe97d38..1dd31ca3f89 100644 --- a/src/HotChocolate/Core/src/Types/Execution/Processing/WorkQueue.cs +++ b/src/HotChocolate/Core/src/Types/Execution/Processing/WorkQueue.cs @@ -10,11 +10,19 @@ internal sealed class WorkQueue public bool IsEmpty => _immediateStack.Count == 0 && _deferredStack.Count == 0; + /// + /// Gets a value indicating whether taken tasks have not been completed yet. + /// The scheduler lock must be held. + /// public bool HasRunningTasks => _running > 0; + /// + /// Marks a taken task as completed and returns true if no taken tasks remain. + /// The scheduler lock must be held. + /// public bool Complete() { - var value = Interlocked.Decrement(ref _running); + var value = --_running; if (value < 0) { @@ -24,12 +32,16 @@ public bool Complete() return value is 0; } + /// + /// Takes the next task, preferring immediate over deferred tasks, and counts it as running. + /// The scheduler lock must be held. + /// public bool TryTake([MaybeNullWhen(false)] out IExecutionTask executionTask) { if (_immediateStack.TryPop(out executionTask) || _deferredStack.TryPop(out executionTask)) { - Interlocked.Increment(ref _running); + _running++; return true; } @@ -50,6 +62,10 @@ public void Push(IExecutionTask executionTask) } } + /// + /// Removes all tasks and resets the running count. + /// Only valid on an idle or discarded scheduler, and does not take the scheduler lock. + /// public void Clear() { _immediateStack.Clear();