Repository: quartznet/quartznet (2026-03-29T17:07:29Z)

Issue:
DedicatedThreadPool: Threads leak when scheduler is shut down
When a scheduler is configured to use a **DedicatedThreadPool**, the threads that are created in **QueuedTaskScheduler** will not be interrupted when the scheduler is shut down.

I think we need to introduce a breaking change in the API signature of `TaskSchedulingThreadPool.GetDefaultScheduler()` to allow us to pass the (shutdown) cancelleation token down to **QueuedTaskScheduler**.



Discussion:
This is pretty advanced API to use so I think it's OK to bend the rules a bit and add it as part of 3.4 release.



Patch:
diff --git a/src/Quartz/Simpl/TaskSchedulingThreadPool.cs b/src/Quartz/Simpl/TaskSchedulingThreadPool.cs
index 5ed0c5e74..4836fef41 100644
--- a/src/Quartz/Simpl/TaskSchedulingThreadPool.cs
+++ b/src/Quartz/Simpl/TaskSchedulingThreadPool.cs
@@ -290,6 +290,9 @@ public void Shutdown(bool waitForJobsToComplete = true)
             logger.LogDebug("No executing jobs remaining, all threads stopped.");
         }
 
+        // Dispose the scheduler to release its resources (e.g. QueuedTaskScheduler threads)
+        (scheduler as IDisposable)?.Dispose();
+
         logger.LogDebug("Shutdown of threadpool complete.");
     }
 }
\ No newline at end of file


Test patch:
diff --git a/src/Quartz.Tests.Unit/Simpl/DedicatedThreadPoolTest.cs b/src/Quartz.Tests.Unit/Simpl/DedicatedThreadPoolTest.cs
new file mode 100644
index 000000000..3b7fcd15c
--- /dev/null
+++ b/src/Quartz.Tests.Unit/Simpl/DedicatedThreadPoolTest.cs
@@ -0,0 +1,68 @@
+using System.Reflection;
+
+using Quartz.Simpl;
+using Quartz.Util;
+
+namespace Quartz.Tests.Unit.Simpl;
+
+public sealed class DedicatedThreadPoolTest
+{
+    [Test]
+    public void Shutdown_ShouldStopQueuedTaskSchedulerThreads()
+    {
+        DedicatedThreadPool pool = new DedicatedThreadPool { ThreadCount = 2 };
+        pool.Initialize();
+
+        QueuedTaskScheduler qts = (QueuedTaskScheduler) pool.Scheduler;
+        Thread[] threads = GetThreads(qts);
+
+        Assert.That(threads.All(t => t.IsAlive), Is.True,
+            "All QueuedTaskScheduler threads should be alive before shutdown");
+
+        pool.Shutdown(waitForJobsToComplete: true);
+
+        foreach (Thread thread in threads)
+        {
+            thread.Join(TimeSpan.FromSeconds(5));
+        }
+
+        Assert.That(threads.All(t => !t.IsAlive), Is.True,
+            "All QueuedTaskScheduler threads should have stopped after shutdown");
+    }
+
+    [Test]
+    public void Shutdown_WithoutWaiting_ShouldStillStopThreads()
+    {
+        DedicatedThreadPool pool = new DedicatedThreadPool { ThreadCount = 1 };
+        pool.Initialize();
+
+        QueuedTaskScheduler qts = (QueuedTaskScheduler) pool.Scheduler;
+        Thread[] threads = GetThreads(qts);
+
+        pool.Shutdown(waitForJobsToComplete: false);
+
+        foreach (Thread thread in threads)
+        {
+            thread.Join(TimeSpan.FromSeconds(5));
+        }
+
+        Assert.That(threads.All(t => !t.IsAlive), Is.True,
+            "QueuedTaskScheduler threads should stop even when not waiting for jobs");
+    }
+
+    [Test]
+    public void Shutdown_DefaultThreadPool_ShouldNotThrow()
+    {
+        DefaultThreadPool pool = new DefaultThreadPool { ThreadCount = 2 };
+        pool.Initialize();
+
+        Assert.DoesNotThrow(() => pool.Shutdown(waitForJobsToComplete: true));
+    }
+
+    private static Thread[] GetThreads(QueuedTaskScheduler qts)
+    {
+        FieldInfo threadsField = typeof(QueuedTaskScheduler)
+            .GetField("_threads", BindingFlags.NonPublic | BindingFlags.Instance)!;
+        return (Thread[]) threadsField.GetValue(qts)!;
+    }
+}