AdaskoTheBeAsT.Interop.Threading 3.1.0

dotnet add package AdaskoTheBeAsT.Interop.Threading --version 3.1.0
                    
NuGet\Install-Package AdaskoTheBeAsT.Interop.Threading -Version 3.1.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="AdaskoTheBeAsT.Interop.Threading" Version="3.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AdaskoTheBeAsT.Interop.Threading" Version="3.1.0" />
                    
Directory.Packages.props
<PackageReference Include="AdaskoTheBeAsT.Interop.Threading" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add AdaskoTheBeAsT.Interop.Threading --version 3.1.0
                    
#r "nuget: AdaskoTheBeAsT.Interop.Threading, 3.1.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package AdaskoTheBeAsT.Interop.Threading@3.1.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=AdaskoTheBeAsT.Interop.Threading&version=3.1.0
                    
Install as a Cake Addin
#tool nuget:?package=AdaskoTheBeAsT.Interop.Threading&version=3.1.0
                    
Install as a Cake Tool

๐Ÿงต AdaskoTheBeAsT.Interop.Threading

๐ŸชŸ A friendly, production-ready Windows threading toolbox for cross-process mutexes, STA/COM work, and task timeouts โ€” with a message pump that actually pumps. ๐Ÿ’จ

NuGet NuGet downloads License: MIT TFMs Platform Warnings Deterministic CI

๐Ÿ”ฌ Code quality โ€” SonarCloud

Quality Gate Status Coverage Maintainability Rating Reliability Rating Security Rating Bugs Vulnerabilities Code Smells Duplicated Lines (%) Technical Debt Lines of Code


๐Ÿ‘‹ Hello, threading friend

Native-on-Windows code is fun, right up until it isn't. You know the signs:

  • ๐Ÿข a COM component that quietly insists on STA + message pumping
  • ๐Ÿ”’ a resource that must be serialized across processes (not just threads)
  • โณ a call that might never come back, so you need a real timeout โ€” one that respects cancellation tokens
  • ๐ŸงŸ a mutex left behind by a process that crashed, waiting to ambush the next caller
  • ๐Ÿงช a scheduler that must behave in unit tests: disposable, injectable, mockable

AdaskoTheBeAsT.Interop.Threading is the reusable boilerplate you keep rewriting in every project: named cross-process mutexes with sensible ACLs, a dedicated STA thread with a real OLE message loop, and task timeouts that distinguish "I gave up" from "the caller canceled me". ๐Ÿ“ฆ

And now it's a library. โœจ


โœจ Why you'll love this

  • ๐Ÿงต Instance-based STA scheduler. SingleThreadedApartmentTaskScheduler owns its own STA thread, implements ISingleThreadedApartmentTaskScheduler + IDisposable, and shuts down deterministically. No more static global state.
  • ๐Ÿงฉ DI-friendly options. SingleThreadedApartmentTaskSchedulerOptions binds to Microsoft.Extensions.Options out of the box.
  • โฑ๏ธ Per-item timeouts. RunAsync(func, timeout, ct) overload plus DefaultWorkItemTimeout on options.
  • ๐Ÿ•Š๏ธ Cooperative cancellation that actually composes. Caller token โจฏ scheduler-shutdown token, observed pre- and post-execution by StaWorkItem.
  • ๐Ÿงฎ Full-precision timing. StaYield uses Stopwatch.GetTimestamp() with full-precision ms โ†’ tick math โ€” no Environment.TickCount wraparound surprises.
  • ๐Ÿ”’ Cross-process mutexes done right. Global \Global\ prefix, cached MutexSecurity, reflection-resolved SetAccessControl (works on net4x and net8+), abandoned-mutex recovery.
  • ๐ŸชŸ 9 TFMs, all green. net10.0, net9.0, net8.0, net481, net48, net472, net471, net47, net462 โ€” the full matrix on every build. Windows-specific surfaces are annotated with [SupportedOSPlatform("windows")] so the analyzer guides cross-platform callers.
  • ๐Ÿ”Ž Source Link + snupkg. Step into the library from your debugger without guessing.
  • ๐Ÿ›ก๏ธ Warnings-as-errors + deterministic builds. Because future-you deserves reproducibility.
  • ๐Ÿ“ Tiny public surface. Seven public types: MutexHelper, SingleThreadedApartmentTask, SingleThreadedApartmentTaskScheduler, ISingleThreadedApartmentTaskScheduler, SingleThreadedApartmentTaskSchedulerOptions, StaYield, TaskExtension.

๐Ÿ“ฆ Install

dotnet add package AdaskoTheBeAsT.Interop.Threading

Or via the NuGet Package Manager console:

Install-Package AdaskoTheBeAsT.Interop.Threading

Symbols ship as .snupkg with Source Link and embedded untracked sources โ€” step in, look around, it's fine.


๐Ÿ“š Table of contents


๐Ÿ—บ๏ธ Target framework matrix

TFM Status Notes
net10.0 โœ… Primary target; LibraryImport source-generated P/Invoke. Windows-only surfaces annotated with [SupportedOSPlatform("windows")].
net9.0 โœ… Primary target; LibraryImport. Windows-only surfaces annotated with [SupportedOSPlatform("windows")].
net8.0 โœ… Primary target; LibraryImport. Windows-only surfaces annotated with [SupportedOSPlatform("windows")].
net481 โœ… Windows desktop; classic DllImport.
net48 โœ… Same as above.
net472 โœ… Same as above.
net471 โœ… Same as above.
net47 โœ… Same as above.
net462 โœ… Minimum supported TFM.

Every cell is built with TreatWarningsAsErrors=true, ContinuousIntegrationBuild=true, Deterministic=true, and exercised in CI.

TaskExtension is cross-platform and usable on any OS. All other public types call Windows APIs (Win32 message pump, OLE, mutex ACLs) and are marked Windows-only โ€” callers on non-Windows platforms will see CA1416 warnings or can guard with OperatingSystem.IsWindows().


๐Ÿ’ก The core idea

Most Windows interop pain comes from four recurring themes. This library gives each one a tiny, focused primitive:

             โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
             โ”‚  Caller (your app / test / service)  โ”‚
             โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                 โ”‚          โ”‚          โ”‚         โ”‚
                 โ”‚ Mutex    โ”‚ STA      โ”‚ Schedule โ”‚ Timeout
                 โ–ผ          โ–ผ          โ–ผ         โ–ผ
         โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
         โ”‚MutexHelperโ”‚ โ”‚SingleThrโ”‚ โ”‚Schedulerโ”‚ โ”‚TaskExtensionโ”‚
         โ”‚  ๐Ÿ”’       โ”‚ โ”‚Apartmentโ”‚ โ”‚  ๐Ÿงต     โ”‚ โ”‚    โฑ๏ธ       โ”‚
         โ”‚           โ”‚ โ”‚Task ๐Ÿข  โ”‚ โ”‚         โ”‚ โ”‚              โ”‚
         โ”‚Global\... โ”‚ โ”‚Ad-hoc   โ”‚ โ”‚Persistentโ”‚ โ”‚TimeoutAfter โ”‚
         โ”‚abandoned- โ”‚ โ”‚STA run  โ”‚ โ”‚STA queue โ”‚ โ”‚Async, CT-   โ”‚
         โ”‚mutex OK   โ”‚ โ”‚per call โ”‚ โ”‚ + pump  โ”‚ โ”‚aware        โ”‚
         โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜
                 โ”‚          โ”‚          โ”‚         โ”‚
                 โ–ผ          โ–ผ          โ–ผ         โ–ผ
             โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
             โ”‚  Win32 / OLE / Message Pump (Windows)โ”‚
             โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Use one or all of them. They're independent, focused primitives, each with a tight test matrix.


๐ŸŽฏ Key Features

1. MutexHelper - Cross-Process Synchronization

Safely run code blocks within a named mutex, ensuring exclusive execution across processes with proper security settings.

Features:

  • Global or local mutex scope
  • Configurable timeout with meaningful exceptions
  • Automatic recovery from abandoned mutexes
  • Security settings allowing cross-session access

Basic Usage:

using AdaskoTheBeAsT.Interop.Threading;

// Simple global mutex
var result = MutexHelper.RunInMutex("MyAppInstance", () => {
    // Only one process can execute this at a time
    return PerformCriticalOperation();
});

With Timeout:

// Timeout after 30 seconds if mutex can't be acquired
var result = MutexHelper.RunInMutex(
    "MyMutexName", 
    TimeSpan.FromSeconds(30), 
    () => {
        return ProcessSharedResource();
    });

Local Mutex (non-global):

// Use local mutex for same-process synchronization
var result = MutexHelper.RunInMutex(
    "LocalMutex",
    TimeSpan.FromSeconds(10),
    isGlobal: false,
    () => DoWork());

2. SingleThreadedApartmentTask - STA Execution

Execute tasks in a Single-Threaded Apartment state, essential for COM interop, Windows clipboard operations, and legacy UI components.

Features:

  • Full STA thread context with COM initialization
  • Cancellation token support
  • Exception propagation
  • Message pump for COM interop
  • Background thread execution (won't block process shutdown)

Basic Usage:

// Execute code on STA thread
var result = await SingleThreadedApartmentTask.RunAsync(
    () => {
        // Code runs in STA apartment state
        // Perfect for COM objects, clipboard, etc.
        return GetDataFromStaComponent();
    },
    cancellationToken);

With Timeout:

// Combine STA execution with timeout
var result = await SingleThreadedApartmentTask.RunWithTimeoutAsync(
    TimeSpan.FromSeconds(30),
    () => {
        return CallLegacyComComponent();
    },
    cancellationToken);

With Message Pump (StaYield):

var result = await SingleThreadedApartmentTask.RunAsync(
    (StaYield staYield) => {
        for (int i = 0; i < 1000; i++) {
            DoWork(i);
            // Pump messages periodically to keep UI responsive
            staYield.Occasionally();
        }
        return result;
    },
    cancellationToken);

3. SingleThreadedApartmentTaskScheduler - Reusable STA Thread

Share a single STA thread across multiple tasks for better performance when you need frequent STA execution.

Features:

  • Persistent STA thread with message loop
  • OLE/COM initialization
  • Queued task execution
  • Cancellation support
  • Instance-based (IDisposable) so each scheduler owns its own STA thread and can be deterministically shut down
  • ISingleThreadedApartmentTaskScheduler interface for dependency injection and unit-testing

Usage:

// Multiple tasks can share the same STA thread
using var scheduler = new SingleThreadedApartmentTaskScheduler();

var task1 = scheduler.RunAsync(() => ComOperation1(), cancellationToken);
var task2 = scheduler.RunAsync(() => ComOperation2(), cancellationToken);

await Task.WhenAll(task1, task2);

With StaYield:

using var scheduler = new SingleThreadedApartmentTaskScheduler();

await scheduler.RunAsync((StaYield staYield) => {
    while (!condition) {
        // Wait for condition while pumping messages
        staYield.SpinUntil(() => CheckCondition(), checkEveryMs: 10);
    }

    // Or sleep without blocking the message pump
    staYield.Sleep(1000);
});

Register as a singleton in DI:

builder.Services.AddSingleton<ISingleThreadedApartmentTaskScheduler>(
    _ => new SingleThreadedApartmentTaskScheduler(
        new SingleThreadedApartmentTaskSchedulerOptions { ThreadName = "App-STA" }));

Or, if you already use Microsoft.Extensions.Options, bind the options from configuration and resolve them in the factory:

builder.Services.Configure<SingleThreadedApartmentTaskSchedulerOptions>(
    builder.Configuration.GetSection("StaScheduler"));
builder.Services.AddSingleton<ISingleThreadedApartmentTaskScheduler>(sp =>
    new SingleThreadedApartmentTaskScheduler(
        sp.GetRequiredService<IOptions<SingleThreadedApartmentTaskSchedulerOptions>>().Value));

4. TaskExtension - Task Timeout Management

Add timeout capabilities to any existing Task with proper cancellation token linking.

Features:

  • Timeout exception with descriptive message
  • Cancellation token propagation
  • Async cancellation on .NET 8+
  • ConfigureAwait(false) for library code

Usage:

using AdaskoTheBeAsT.Interop.Threading;

// Add timeout to any task
var result = await LongRunningOperation()
    .TimeoutAfterAsync(TimeSpan.FromSeconds(30), cancellationToken);

With Existing Task:

// Works with any Task<T>
var dataTask = FetchDataAsync();
try {
    var data = await dataTask.TimeoutAfterAsync(TimeSpan.FromSeconds(5), cancellationToken);
    ProcessData(data);
} catch (TimeoutException) {
    Logger.Warning("Operation timed out after 5 seconds");
}

๐Ÿ”ง Advanced Scenarios

StaYield - Message Pump Control

When running long operations in STA threads, use StaYield to keep the message pump responsive.

// Works identically with SingleThreadedApartmentTask or an instance of SingleThreadedApartmentTaskScheduler
var result = await SingleThreadedApartmentTask.RunAsync((StaYield staYield) => {
    var items = GetLargeItemList();
    
    foreach (var item in items) {
        ProcessItem(item);
        
        // Pump messages every 15ms (default) during long loops
        staYield.Occasionally();
    }
    
    // Wait for a condition without blocking messages
    staYield.SpinUntil(() => IsReady(), checkEveryMs: 10);
    
    // Sleep while keeping message pump active
    staYield.Sleep(1000);
    
    return GetResults();
}, cancellationToken);

Handling Abandoned Mutexes

The library automatically handles abandoned mutexes (when a process crashes while holding the mutex):

// If another process crashes while holding the mutex,
// this will log a warning and continue execution
var result = MutexHelper.RunInMutex("SharedResource", () => {
    // Your code here - the library handles recovery
    return DoWork();
});

๐ŸŽ“ Real-World Examples

Single Instance Application

public class Program {
    private const string MutexName = "MyApp_SingleInstance";
    
    public static void Main(string[] args) {
        try {
            MutexHelper.RunInMutex(MutexName, TimeSpan.Zero, () => {
                // Application code here
                RunApplication();
                return 0;
            });
        } catch (TimeoutException) {
            Console.WriteLine("Application is already running!");
            Environment.Exit(1);
        }
    }
}

COM Interop with Timeout

public async Task<string> GetClipboardTextAsync(CancellationToken ct) {
    return await SingleThreadedApartmentTask.RunWithTimeoutAsync(
        TimeSpan.FromSeconds(5),
        () => {
            // Clipboard operations require STA thread
            return Clipboard.GetText();
        },
        ct);
}

Using with AdaskoTheBeAsT.Interop.COM

AdaskoTheBeAsT.Interop.COM handles registration-free COM activation, while this library provides the STA thread and scheduling model around those calls.

For a one-off COM calculation with timeout:

var value = await SingleThreadedApartmentTask.RunWithTimeoutAsync(
    TimeSpan.FromSeconds(10),
    () =>
    {
        decimal result = default;
        var execution = Executor.Execute(comDllPath, manifestPath, () =>
        {
            var calculator = new LegacyCalculator.CalculatorClass();
            result = calculator.Add(left, right);
        });

        if (!execution.Success)
        {
            throw new InvalidOperationException("The COM calculation failed.", execution.Exception);
        }

        return result;
    },
    cancellationToken);

For repeated COM calculations that must stay serialized on one STA thread, create an instance of SingleThreadedApartmentTaskScheduler and reuse it (see the hosted-service example below).

See Using AdaskoTheBeAsT.Interop.Threading with AdaskoTheBeAsT.Interop.COM for a full guide.

Hosted Service for serialized COM requests

If the COM server hangs when multiple callers invoke it in parallel, put a queue in front of a single STA worker.

The pattern is:

  1. create the COM object once in StartAsync by calling Executor.Create(...) on the scheduler thread and keep the returned ComObjectHandle<T>;
  2. accept incoming requests through a queue;
  3. process each request through SingleThreadedApartmentTaskScheduler.RunAsync(...) so every call stays on the same STA thread and runs one-by-one.
  4. release the handle in StopAsync by calling Executor.Free(...) on that same scheduler thread.
using System.Threading.Channels;
using AdaskoTheBeAsT.Interop.COM;
using AdaskoTheBeAsT.Interop.Threading;
using Microsoft.Extensions.Hosting;

public sealed class CalculationRequest
{
    public required decimal Left { get; init; }
    public required decimal Right { get; init; }
    public required TaskCompletionSource<decimal> Completion { get; init; }
}

public sealed class ComCalculationHostedService : BackgroundService
{
    private readonly Channel<CalculationRequest> _requests = Channel.CreateUnbounded<CalculationRequest>();
    private readonly ISingleThreadedApartmentTaskScheduler _scheduler;
    private readonly string _comDllPath;
    private readonly string _manifestPath;
    private ComObjectHandle<LegacyCalculator.CalculatorClass>? _calculatorHandle;
    private LegacyCalculator.CalculatorClass? _calculator;

    public ComCalculationHostedService(ISingleThreadedApartmentTaskScheduler scheduler)
    {
        _scheduler = scheduler;
        var basePath = AppContext.BaseDirectory;
        _comDllPath = Path.Combine(basePath, "LegacyCalculator.dll");
        _manifestPath = Path.Combine(basePath, "LegacyCalculator.manifest");
    }

    public override async Task StartAsync(CancellationToken cancellationToken)
    {
        await _scheduler.RunAsync(
            () =>
            {
                var creation = Executor.Create(
                    _comDllPath,
                    _manifestPath,
                    () => new LegacyCalculator.CalculatorClass());

                if (!creation.Success)
                {
                    throw new InvalidOperationException(
                        "Failed to initialize the COM calculator.",
                        creation.Exception);
                }

                _calculatorHandle = creation.Value
                    ?? throw new InvalidOperationException("The COM calculator handle was not created.");
                _calculator = _calculatorHandle.ComObject
                    ?? throw new InvalidOperationException("The COM calculator instance was not created.");

                return 0;
            },
            cancellationToken);

        await base.StartAsync(cancellationToken);
    }

    public async Task<decimal> AddAsync(decimal left, decimal right, CancellationToken cancellationToken)
    {
        var completion = new TaskCompletionSource<decimal>(TaskCreationOptions.RunContinuationsAsynchronously);

        using var registration = cancellationToken.Register(
            static state => ((TaskCompletionSource<decimal>)state!).TrySetCanceled(),
            completion);

        await _requests.Writer.WriteAsync(
            new CalculationRequest
            {
                Left = left,
                Right = right,
                Completion = completion,
            },
            cancellationToken);

        return await completion.Task;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        await foreach (var request in _requests.Reader.ReadAllAsync(stoppingToken))
        {
            try
            {
                var value = await _scheduler.RunAsync(
                    () =>
                    {
                        if (_calculator is null)
                        {
                            throw new InvalidOperationException("The COM calculator is not initialized.");
                        }

                        return _calculator.Add(request.Left, request.Right);
                    },
                    stoppingToken);

                request.Completion.TrySetResult(value);
            }
            catch (Exception ex)
            {
                request.Completion.TrySetException(ex);
            }
        }
    }

    public override async Task StopAsync(CancellationToken cancellationToken)
    {
        _requests.Writer.TryComplete();
        await base.StopAsync(cancellationToken);

        await _scheduler.RunAsync(
            () =>
            {
                if (_calculatorHandle is not null)
                {
                    var release = Executor.Free(_calculatorHandle);

                    _calculator = null;
                    _calculatorHandle = null;

                    if (!release.Success)
                    {
                        throw new InvalidOperationException(
                            "Failed to release the COM calculator.",
                            release.Exception);
                    }
                }

                return 0;
            },
            cancellationToken);
    }
}

Register it once and expose the same instance both as hosted service and as an injectable service. Register the scheduler as a singleton so it owns a single STA thread for the app lifetime:

builder.Services.AddSingleton<ISingleThreadedApartmentTaskScheduler>(
    _ => new SingleThreadedApartmentTaskScheduler(
        new SingleThreadedApartmentTaskSchedulerOptions { ThreadName = "App-STA" }));
builder.Services.AddSingleton<ComCalculationHostedService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<ComCalculationHostedService>());

This pattern is useful when:

  • the COM object must always be created and used on the same STA thread;
  • parallel calls would deadlock or hang the COM server;
  • you want the rest of the application to remain async while the COM work is serialized behind the queue.

Multiple different COM components in the same app

Each SingleThreadedApartmentTaskScheduler instance owns one reusable STA thread. If you send every COM component through the same instance, all calls will be serialized on that STA thread. If you need parallelism across components, create one scheduler instance per STA lane.

When the components are unrelated and do not need to share the same long-lived STA-bound instance, prefer SingleThreadedApartmentTask.RunAsync(...) or SingleThreadedApartmentTask.RunWithTimeoutAsync(...). Each call gets its own temporary STA thread, so different COM components can run independently.

using System.Runtime.InteropServices;
using AdaskoTheBeAsT.Interop.COM;
using AdaskoTheBeAsT.Interop.Threading;

public sealed class MultiComFacade
{
    private readonly string _calculatorDllPath;
    private readonly string _calculatorManifestPath;
    private readonly string _reportDllPath;
    private readonly string _reportManifestPath;

    public MultiComFacade(
        string calculatorDllPath,
        string calculatorManifestPath,
        string reportDllPath,
        string reportManifestPath)
    {
        _calculatorDllPath = calculatorDllPath;
        _calculatorManifestPath = calculatorManifestPath;
        _reportDllPath = reportDllPath;
        _reportManifestPath = reportManifestPath;
    }

    public Task<decimal> AddAsync(decimal left, decimal right, CancellationToken cancellationToken)
        => SingleThreadedApartmentTask.RunWithTimeoutAsync(
            TimeSpan.FromSeconds(10),
            () =>
            {
                decimal result = default;
                LegacyCalculator.CalculatorClass? calculator = null;

                var execution = Executor.Execute(_calculatorDllPath, _calculatorManifestPath, () =>
                {
                    calculator = new LegacyCalculator.CalculatorClass();
                    result = calculator.Add(left, right);
                });

                if (calculator is not null)
                {
                    Marshal.FinalReleaseComObject(calculator);
                }

                if (!execution.Success)
                {
                    throw new InvalidOperationException(
                        "The calculator COM call failed.",
                        execution.Exception);
                }

                return result;
            },
            cancellationToken);

    public Task<string> BuildReportAsync(int reportId, CancellationToken cancellationToken)
        => SingleThreadedApartmentTask.RunWithTimeoutAsync(
            TimeSpan.FromSeconds(30),
            () =>
            {
                string report = string.Empty;
                LegacyReporting.ReportGeneratorClass? generator = null;

                var execution = Executor.Execute(_reportDllPath, _reportManifestPath, () =>
                {
                    generator = new LegacyReporting.ReportGeneratorClass();
                    report = generator.Build(reportId);
                });

                if (generator is not null)
                {
                    Marshal.FinalReleaseComObject(generator);
                }

                if (!execution.Success)
                {
                    throw new InvalidOperationException(
                        "The report COM call failed.",
                        execution.Exception);
                }

                return report;
            },
            cancellationToken);
}

That lets you do this:

var addTask = multiComFacade.AddAsync(10m, 5m, cancellationToken);
var reportTask = multiComFacade.BuildReportAsync(42, cancellationToken);

await Task.WhenAll(addTask, reportTask);
If the COM object should be instantiated only once

Then keep using the ComCalculationHostedService / SingleThreadedApartmentTaskScheduler pattern for that component. SingleThreadedApartmentTask creates a new temporary STA thread per call, so it is the right choice only when the COM object is created, used, and released inside that single invocation.

For a reusable COM instance:

  1. create it once in StartAsync on the injected scheduler instance with Executor.Create(...);
  2. keep the returned ComObjectHandle<T> alive for the whole hosted-service lifetime;
  3. store the COM instance in a field;
  4. route every operation back through the same ISingleThreadedApartmentTaskScheduler instance via _scheduler.RunAsync(...);
  5. release the handle in StopAsync with Executor.Free(...) on the same scheduler thread.

The call path for each request then looks like this:

public Task<decimal> AddAsync(decimal left, decimal right, CancellationToken cancellationToken)
    => _scheduler.RunAsync(
        () =>
        {
            if (_calculator is null)
            {
                throw new InvalidOperationException("The COM calculator is not initialized.");
            }

            return _calculator.Add(left, right);
        },
        cancellationToken);
If several different COM objects should each be instantiated only once

If those COM objects can all live on the same STA thread, create them together in one hosted service with Executor.Create(...), keep one ComObjectHandle<T> per object, and reuse the instances through a single injected ISingleThreadedApartmentTaskScheduler instance.

private ComObjectHandle<LegacyCalculator.CalculatorClass>? _calculatorHandle;
private LegacyCalculator.CalculatorClass? _calculator;
private ComObjectHandle<LegacyReporting.ReportGeneratorClass>? _reportGeneratorHandle;
private LegacyReporting.ReportGeneratorClass? _reportGenerator;

public override async Task StartAsync(CancellationToken cancellationToken)
{
    await _scheduler.RunAsync(
        () =>
        {
            var calculatorCreation = Executor.Create(
                _calculatorDllPath,
                _calculatorManifestPath,
                () => new LegacyCalculator.CalculatorClass());

            if (!calculatorCreation.Success)
            {
                throw new InvalidOperationException(
                    "Failed to initialize the calculator COM component.",
                    calculatorCreation.Exception);
            }

            _calculatorHandle = calculatorCreation.Value
                ?? throw new InvalidOperationException("The calculator COM handle was not created.");
            _calculator = _calculatorHandle.ComObject
                ?? throw new InvalidOperationException("The calculator COM instance was not created.");

            var reportCreation = Executor.Create(
                _reportDllPath,
                _reportManifestPath,
                () => new LegacyReporting.ReportGeneratorClass());

            if (!reportCreation.Success)
            {
                throw new InvalidOperationException(
                    "Failed to initialize the reporting COM component.",
                    reportCreation.Exception);
            }

            _reportGeneratorHandle = reportCreation.Value
                ?? throw new InvalidOperationException("The reporting COM handle was not created.");
            _reportGenerator = _reportGeneratorHandle.ComObject
                ?? throw new InvalidOperationException("The reporting COM instance was not created.");

            return 0;
        },
        cancellationToken);

    await base.StartAsync(cancellationToken);
}

public Task<decimal> AddAsync(decimal left, decimal right, CancellationToken cancellationToken)
    => _scheduler.RunAsync(
        () =>
        {
            if (_calculator is null)
            {
                throw new InvalidOperationException("The calculator COM component is not initialized.");
            }

            return _calculator.Add(left, right);
        },
        cancellationToken);

public Task<string> BuildReportAsync(int reportId, CancellationToken cancellationToken)
    => _scheduler.RunAsync(
        () =>
        {
            if (_reportGenerator is null)
            {
                throw new InvalidOperationException("The reporting COM component is not initialized.");
            }

            return _reportGenerator.Build(reportId);
        },
        cancellationToken);

Release every handle in StopAsync on the same scheduler thread by calling Executor.Free(...), just like in the single-component hosted service example above.

This gives you one app-wide STA lane where multiple COM components are instantiated once and reused safely.

If those reusable components must run in parallel, a single SingleThreadedApartmentTaskScheduler instance is not enough because it is one STA thread. In that case create multiple scheduler instances โ€” one per STA lane โ€” and route each COM component through its own instance (register them keyed in DI, or wrap them in per-component services).

Use this mixed approach in one application:

  • use one SingleThreadedApartmentTaskScheduler instance for each component that must stay alive on a dedicated STA thread across many requests;
  • use SingleThreadedApartmentTask for one-off or isolated calls to other COM components;
  • keep all operations for a single STA-bound COM instance inside the same scheduled workflow.

Periodic Task with Cancellation

public async Task RunPeriodicTaskAsync(
    ISingleThreadedApartmentTaskScheduler scheduler,
    CancellationToken ct)
{
    await scheduler.RunAsync((StaYield staYield) => {
        while (!ct.IsCancellationRequested) {
            PerformWork();

            // Sleep 10s while keeping message pump active
            staYield.Sleep(10000);
        }
    }, ct);
}

๐Ÿ—๏ธ Technical Details

  • Frameworks: .NET 10.0, .NET 9.0, .NET 8.0, .NET Framework 4.8.1, 4.8, 4.7.2, 4.7.1, 4.7, 4.6.2
  • Platform: Windows-only runtime surface (uses Win32 APIs for message pumps and COM). Annotated with [SupportedOSPlatform("windows")] on .NET 8+. TaskExtension is cross-platform.
  • P/Invoke: Uses modern LibraryImport source generators on .NET 8+ and DllImport on .NET Framework targets
  • Thread Safety: All APIs are thread-safe
  • Async/Await: Full async/await support with proper ConfigureAwait usage

๐Ÿงญ Architecture Decision Records

Recent hardening changes were made so that:

  • TimeoutAfterAsync correctly distinguishes caller cancellation from an actual timeout;
  • SingleThreadedApartmentTaskScheduler preserves original exceptions from queued work;
  • queued STA operations no longer risk returning a canceled wrapper task after the work has already been accepted for execution.

๐Ÿ“ License

This project is licensed under the MIT License - see the LICENSE file for details.

๐Ÿค Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

โš ๏ธ Known Considerations

  • Windows-only library (relies on Win32 message pump and OLE APIs)
  • Each SingleThreadedApartmentTaskScheduler instance creates a persistent background STA thread; dispose the instance to stop it deterministically
  • STA threads have lower performance than MTA threads - use only when necessary (COM interop, clipboard, etc.)

๐Ÿ”„ Migration Guide

From 2.x to 3.0

SingleThreadedApartmentTaskScheduler has been converted from a static class into an instance class that implements ISingleThreadedApartmentTaskScheduler and IDisposable. Each instance owns its own STA thread, which fixes several long-standing limitations (no deterministic shutdown, no isolation between tests, no way to run parallel STA lanes, no DI story).

This is a breaking change. The static SingleThreadedApartmentTaskScheduler.RunAsync(...) / Shutdown() members no longer exist; callers must now create an instance.

Before (2.x)
var task1 = SingleThreadedApartmentTaskScheduler.RunAsync(() => ComOperation1(), ct);
var task2 = SingleThreadedApartmentTaskScheduler.RunAsync(() => ComOperation2(), ct);
await Task.WhenAll(task1, task2);

SingleThreadedApartmentTaskScheduler.Shutdown();
After (3.0)
using var scheduler = new SingleThreadedApartmentTaskScheduler();

var task1 = scheduler.RunAsync(() => ComOperation1(), ct);
var task2 = scheduler.RunAsync(() => ComOperation2(), ct);
await Task.WhenAll(task1, task2);

// Disposing the instance shuts the STA thread down deterministically
// and cancels any queued-but-not-yet-executed items.

For application-wide use, register one scheduler per STA lane as a DI singleton. Use SingleThreadedApartmentTaskSchedulerOptions to configure it โ€” the constructor takes an options object so there is no ambiguous string parameter for the container to resolve:

builder.Services.AddSingleton<ISingleThreadedApartmentTaskScheduler>(
    _ => new SingleThreadedApartmentTaskScheduler(
        new SingleThreadedApartmentTaskSchedulerOptions { ThreadName = "App-STA" }));

Then inject ISingleThreadedApartmentTaskScheduler wherever you previously called the static API. The DI container will dispose the scheduler on application shutdown.

If you want to postpone the full migration, wrap one instance behind your own static helper:

internal static class AppSta
{
    public static ISingleThreadedApartmentTaskScheduler Default { get; }
        = new SingleThreadedApartmentTaskScheduler(
            new SingleThreadedApartmentTaskSchedulerOptions { ThreadName = "App-STA" });
}

// Call sites:
await AppSta.Default.RunAsync(() => ComOperation(), ct);

This restores the old call-site ergonomics but also retains the old drawback of a single process-wide STA thread that never shuts down before process exit.

๐Ÿ“‹ Changelog

3.1.0

  • TFMs changed from net10.0-windows;net9.0-windows;net8.0-windows to plain cross-platform net10.0;net9.0;net8.0 (plus the existing net4.6.2..net4.8.1). The library can now be referenced from cross-platform projects.
  • Windows-specific types are annotated with [SupportedOSPlatform("windows")] (guarded by #if NET8_0_OR_GREATER) so the platform compatibility analyzer (CA1416) guides callers correctly:
    • Annotated: MutexHelper, SingleThreadedApartmentTask, SingleThreadedApartmentTaskScheduler, ISingleThreadedApartmentTaskScheduler, StaYield, NativeMethods, StaWorkItem<T>, IStaWorkItem.
    • Cross-platform: TaskExtension (pure Task/CancellationToken code) and SingleThreadedApartmentTaskSchedulerOptions (POCO).
  • No runtime behavior change on Windows; consumers on net8.0-windows/net9.0-windows/net10.0-windows continue to work unchanged.
  • Note for cross-platform callers: projects on plain net8.0/net9.0/net10.0 that invoke Windows-specific APIs will now see CA1416 warnings at the call sites. Projects with TreatWarningsAsErrors=true may need to add OperatingSystem.IsWindows() guards or suppress CA1416 locally.

3.0.0 (breaking)

  • Breaking: SingleThreadedApartmentTaskScheduler is now an instance class (previously static). See the Migration Guide above.
  • Added ISingleThreadedApartmentTaskScheduler interface to enable dependency injection and mocking.
  • Added IDisposable support โ€” disposing the scheduler joins its STA thread, cancels any pending queued items, and releases the underlying synchronization handles.
  • Added ObjectDisposedException thrown from RunAsync after disposal.
  • Added SingleThreadedApartmentTaskSchedulerOptions configuration class (currently exposes ThreadName). The constructor takes an options instance, which plays nicely with DI and Microsoft.Extensions.Options.
  • Surface OLE initialization failure to callers instead of silently leaving the thread dead: subsequent RunAsync calls fault with an InvalidOperationException containing the HRESULT.
  • Multiple scheduler instances can now coexist, each with its own STA thread (enables per-component STA lanes and isolated unit tests).

2.x

  • MutexHelper, SingleThreadedApartmentTask, TaskExtension, and StaYield hardened against cancellation-vs-timeout races and exception wrapping (see ADR 0001).
Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
.NET Framework net462 is compatible.  net463 was computed.  net47 is compatible.  net471 is compatible.  net472 is compatible.  net48 is compatible.  net481 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.1.0 101 4/20/2026
3.0.0 101 4/20/2026
2.1.0 99 4/7/2026
2.0.0 95 4/7/2026
1.0.0 206 11/23/2025

Enhanced STA task execution and COM interop support.