This is not a duplicate of Task not garbage collected. The symptoms are similar though.
The code below is a console app that creates an STA thread for use with WinForms. Tasks are posted to that thread via a custom task scheduler obtained with TaskScheduler.FromCurrentSynchronizationContext, which just implicitly wraps an instance of WindowsFormsSynchronizationContext here.
Depending on what causes this STA thread to end, the final task var terminatorTask = Run(() => Application.ExitThread()), scheduled in the WinformsApartment.Dispose method, may not always be getting a chance to execute. Regardless of that, I believe this task still should be getting garbage-collected, but it isn't. Why?
Here's a self-contained example illustrating that (s_debugTaskRef.IsAlive is true at the finish), tested with .NET 4.8, both Debug and Release:
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace ConsoleTest
{
    class Program
    {
        // entry point
        static async Task Main(string[] args)
        {
            try
            {
                using (var apartment = new WinformsApartment(() => new Form()))
                {
                    await Task.Delay(1000);
                    await apartment.Run(() => Application.ExitThread());
                }
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Error: {ex.Message}");
                Environment.Exit(-1);
            }
            GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced);
            GC.WaitForPendingFinalizers();
            Console.WriteLine($"IsAlive: {WinformsApartment.s_debugTaskRef.IsAlive}");
            Console.ReadLine();
        }
    }
    public class WinformsApartment : IDisposable
    {
        readonly Thread _thread; // the STA thread
        readonly TaskScheduler _taskScheduler; // the STA thread's task scheduler
        readonly Task _threadEndTask; // to keep track of the STA thread completion
        readonly object _lock = new object();
        public TaskScheduler TaskScheduler { get { return _taskScheduler; } }
        public Task AsTask { get { return _threadEndTask; } }
        /// <summary>MessageLoopApartment constructor</summary>
        public WinformsApartment(Func<Form> createForm)
        {
            var schedulerTcs = new TaskCompletionSource<TaskScheduler>();
            var threadEndTcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
            // start an STA thread and gets a task scheduler
            _thread = new Thread(_ =>
            {
                try
                {
                    // handle Application.Idle just once
                    // to make sure we're inside the message loop
                    // and the proper synchronization context has been correctly installed
                    void onIdle(object s, EventArgs e) {
                        Application.Idle -= onIdle;
                        // make the task scheduler available
                        schedulerTcs.SetResult(TaskScheduler.FromCurrentSynchronizationContext());
                    };
                    Application.Idle += onIdle;
                    Application.Run(createForm());
                    threadEndTcs.TrySetResult(true);
                }
                catch (Exception ex)
                {
                    threadEndTcs.TrySetException(ex);
                }
            });
            async Task waitForThreadEndAsync()
            {
                // we use TaskCreationOptions.RunContinuationsAsynchronously
                // to make sure thread.Join() won't try to join itself
                Debug.Assert(Thread.CurrentThread != _thread);
                await threadEndTcs.Task.ConfigureAwait(false);
                _thread.Join();
            }
            _thread.SetApartmentState(ApartmentState.STA);
            _thread.IsBackground = true;
            _thread.Start();
            _taskScheduler = schedulerTcs.Task.Result;
            _threadEndTask = waitForThreadEndAsync();
        }
        // TODO: it's here for debugging leaks
        public static readonly WeakReference s_debugTaskRef = new WeakReference(null); 
        /// <summary>shutdown the STA thread</summary>
        public void Dispose()
        {
            lock(_lock)
            {
                if (Thread.CurrentThread == _thread)
                    throw new InvalidOperationException();
                if (!_threadEndTask.IsCompleted)
                {
                    // execute Application.ExitThread() on the STA thread
                    var terminatorTask = Run(() => Application.ExitThread());
                    s_debugTaskRef.Target = terminatorTask; // TODO: it's here for debugging leaks
                    _threadEndTask.GetAwaiter().GetResult();
                }
            }
        }
        /// <summary>Task.Factory.StartNew wrappers</summary>
        public Task Run(Action action, CancellationToken token = default(CancellationToken))
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler);
        }
        public Task<TResult> Run<TResult>(Func<TResult> action, CancellationToken token = default(CancellationToken))
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler);
        }
        public Task Run(Func<Task> action, CancellationToken token = default(CancellationToken))
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler).Unwrap();
        }
        public Task<TResult> Run<TResult>(Func<Task<TResult>> action, CancellationToken token = default(CancellationToken))
        {
            return Task.Factory.StartNew(action, token, TaskCreationOptions.None, _taskScheduler).Unwrap();
        }
    }
}
I suspect this might be a .NET Framework bug. I'm currently investigating it and I'll post what I may find, but maybe someone could provide an explanation right away.
