From 647b2ae0166cd56136ded2ddbd1378367e0fedd6 Mon Sep 17 00:00:00 2001 From: George Shaw Date: Fri, 5 Jun 2026 18:40:37 +0100 Subject: [PATCH 1/2] #204: Use default scheduler for worker tasks AsynchronousWorker called Task.Factory.StartNew without a TaskScheduler argument. The default behaviour inherits the task scheduler from the parent task. If the parent task was explicitly scheduled in a particular synchronisation context (e.g. the main thread), the worker tasks would use that same context. This was incorrect - worker tasks should always be in background threads. To correct this, I am explicitly specifying TaskScheduler.Default for scheduling the worker tasks, so they always run on background threads, off the caller's scheduler. --- src/StatsdClient/Worker/AsynchronousWorker.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/StatsdClient/Worker/AsynchronousWorker.cs b/src/StatsdClient/Worker/AsynchronousWorker.cs index ed4f72af..23be642d 100644 --- a/src/StatsdClient/Worker/AsynchronousWorker.cs +++ b/src/StatsdClient/Worker/AsynchronousWorker.cs @@ -36,7 +36,7 @@ public AsynchronousWorker( _optionalExceptionHandler = optionalExceptionHandler; for (int i = 0; i < workerThreadCount; ++i) { - _workers.Add(Task.Factory.StartNew(() => Dequeue(), TaskCreationOptions.LongRunning)); + _workers.Add(Task.Factory.StartNew(() => Dequeue(), CancellationToken.None, TaskCreationOptions.LongRunning, TaskScheduler.Default)); } } From 15a89662d5faf83bf6a961e45749384e45674ac8 Mon Sep 17 00:00:00 2001 From: Jordan Gonzalez <30836115+duncanista@users.noreply.github.com> Date: Fri, 5 Jun 2026 13:43:59 -0400 Subject: [PATCH 2/2] Add regression test for #204 Verifies that AsynchronousWorker does not inherit a caller-supplied TaskScheduler. The test pins construction to a TaskScheduler that counts QueueTask invocations; if the worker tasks were to inherit the caller's scheduler, they would be queued there too. With the fix in place, only the outer construction task is queued. --- .../Worker/AsynchronousWorkerTests.cs | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/StatsdClient.Tests/Worker/AsynchronousWorkerTests.cs b/tests/StatsdClient.Tests/Worker/AsynchronousWorkerTests.cs index a9802440..248fe101 100644 --- a/tests/StatsdClient.Tests/Worker/AsynchronousWorkerTests.cs +++ b/tests/StatsdClient.Tests/Worker/AsynchronousWorkerTests.cs @@ -94,6 +94,33 @@ public void Flush() } } + // Regression test for https://github.com/DataDog/dogstatsd-csharp-client/issues/204. + // The worker must not inherit the caller's TaskScheduler; otherwise a long-running + // Dequeue() loop can end up pinned to a UI SynchronizationContext and freeze the app. + [Test] + [Timeout(5000)] + public void WorkerTasksDoNotInheritCallerScheduler() + { + const int workerThreadCount = 3; + var trackingScheduler = new TrackingTaskScheduler(); + + var outerTask = Task.Factory.StartNew( + () => CreateWorker(workerThreadCount: workerThreadCount), + CancellationToken.None, + TaskCreationOptions.None, + trackingScheduler); + + Assert.IsTrue(outerTask.Wait(TimeSpan.FromSeconds(3))); + + // The only task queued on the tracking scheduler should be the outer task itself. + // If AsynchronousWorker inherits TaskScheduler.Current, the worker tasks would be + // queued here too, bringing the count up to 1 + workerThreadCount. + Assert.AreEqual( + 1, + trackingScheduler.QueueCount, + "AsynchronousWorker leaked worker tasks onto the caller's TaskScheduler (issue #204)."); + } + #if NETFRAMEWORK /// /// This test can only fail when run on the .NET Framework in 64-bit release build using RyuJIT. @@ -127,6 +154,23 @@ private AsynchronousWorker CreateWorker(int workerThreadCount = 2) return worker; } + private sealed class TrackingTaskScheduler : TaskScheduler + { + private int _queueCount; + + public int QueueCount => Volatile.Read(ref _queueCount); + + protected override void QueueTask(Task task) + { + Interlocked.Increment(ref _queueCount); + ThreadPool.UnsafeQueueUserWorkItem(_ => TryExecuteTask(task), null); + } + + protected override bool TryExecuteTaskInline(Task task, bool taskWasPreviouslyQueued) => false; + + protected override IEnumerable GetScheduledTasks() => Array.Empty(); + } + #if NETFRAMEWORK private class AppDomainDelegate : MarshalByRefObject {