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
<PackageReference Include="AdaskoTheBeAsT.Interop.Threading" Version="3.1.0" />
<PackageVersion Include="AdaskoTheBeAsT.Interop.Threading" Version="3.1.0" />
<PackageReference Include="AdaskoTheBeAsT.Interop.Threading" />
paket add AdaskoTheBeAsT.Interop.Threading --version 3.1.0
#r "nuget: AdaskoTheBeAsT.Interop.Threading, 3.1.0"
#:package AdaskoTheBeAsT.Interop.Threading@3.1.0
#addin nuget:?package=AdaskoTheBeAsT.Interop.Threading&version=3.1.0
#tool nuget:?package=AdaskoTheBeAsT.Interop.Threading&version=3.1.0
๐งต 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. ๐จ
๐ฌ Code quality โ SonarCloud
๐ 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.
SingleThreadedApartmentTaskSchedulerowns its own STA thread, implementsISingleThreadedApartmentTaskScheduler+IDisposable, and shuts down deterministically. No more static global state. - ๐งฉ DI-friendly options.
SingleThreadedApartmentTaskSchedulerOptionsbinds toMicrosoft.Extensions.Optionsout of the box. - โฑ๏ธ Per-item timeouts.
RunAsync(func, timeout, ct)overload plusDefaultWorkItemTimeouton options. - ๐๏ธ Cooperative cancellation that actually composes. Caller token โจฏ scheduler-shutdown token, observed pre- and post-execution by
StaWorkItem. - ๐งฎ Full-precision timing.
StaYieldusesStopwatch.GetTimestamp()with full-precision ms โ tick math โ noEnvironment.TickCountwraparound surprises. - ๐ Cross-process mutexes done right. Global
\Global\prefix, cachedMutexSecurity, reflection-resolvedSetAccessControl(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
- ๐ Hello, threading friend
- โจ Why you'll love this
- ๐ฆ Install
- ๐บ๏ธ Target framework matrix
- ๐ก The core idea
- ๐ฏ Key features
- ๐ง Advanced scenarios
- ๐ Real-world examples
- ๐๏ธ Technical details
- ๐งญ Architecture decision records
- โ ๏ธ Known considerations
- ๐ Migration guide (2.x โ 3.0)
- ๐ Changelog
- ๐ License
- ๐ค Contributing
๐บ๏ธ 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 ISingleThreadedApartmentTaskSchedulerinterface 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:
- create the COM object once in
StartAsyncby callingExecutor.Create(...)on the scheduler thread and keep the returnedComObjectHandle<T>; - accept incoming requests through a queue;
- process each request through
SingleThreadedApartmentTaskScheduler.RunAsync(...)so every call stays on the same STA thread and runs one-by-one. - release the handle in
StopAsyncby callingExecutor.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:
- create it once in
StartAsyncon the injected scheduler instance withExecutor.Create(...); - keep the returned
ComObjectHandle<T>alive for the whole hosted-service lifetime; - store the COM instance in a field;
- route every operation back through the same
ISingleThreadedApartmentTaskSchedulerinstance via_scheduler.RunAsync(...); - release the handle in
StopAsyncwithExecutor.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
SingleThreadedApartmentTaskSchedulerinstance for each component that must stay alive on a dedicated STA thread across many requests; - use
SingleThreadedApartmentTaskfor 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+.TaskExtensionis cross-platform. - P/Invoke: Uses modern
LibraryImportsource generators on .NET 8+ andDllImporton .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:
TimeoutAfterAsynccorrectly distinguishes caller cancellation from an actual timeout;SingleThreadedApartmentTaskSchedulerpreserves 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
SingleThreadedApartmentTaskSchedulerinstance 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.
Recommended pattern: register once as a singleton
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.
Quick fix via a shared static (not recommended, but source-minimal)
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-windowsto plain cross-platformnet10.0;net9.0;net8.0(plus the existingnet4.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) andSingleThreadedApartmentTaskSchedulerOptions(POCO).
- Annotated:
- No runtime behavior change on Windows; consumers on
net8.0-windows/net9.0-windows/net10.0-windowscontinue to work unchanged. - Note for cross-platform callers: projects on plain
net8.0/net9.0/net10.0that invoke Windows-specific APIs will now seeCA1416warnings at the call sites. Projects withTreatWarningsAsErrors=truemay need to addOperatingSystem.IsWindows()guards or suppressCA1416locally.
3.0.0 (breaking)
- Breaking:
SingleThreadedApartmentTaskScheduleris now an instance class (previouslystatic). See the Migration Guide above. - Added
ISingleThreadedApartmentTaskSchedulerinterface to enable dependency injection and mocking. - Added
IDisposablesupport โ disposing the scheduler joins its STA thread, cancels any pending queued items, and releases the underlying synchronization handles. - Added
ObjectDisposedExceptionthrown fromRunAsyncafter disposal. - Added
SingleThreadedApartmentTaskSchedulerOptionsconfiguration class (currently exposesThreadName). The constructor takes an options instance, which plays nicely with DI andMicrosoft.Extensions.Options. - Surface OLE initialization failure to callers instead of silently leaving the thread dead: subsequent
RunAsynccalls fault with anInvalidOperationExceptioncontaining 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, andStaYieldhardened against cancellation-vs-timeout races and exception wrapping (see ADR 0001).
| Product | Versions 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. |
-
.NETFramework 4.6.2
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7.1
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7.2
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.8
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.8.1
- System.Threading.AccessControl (>= 10.0.6 && < 11.0.0)
-
net10.0
- No dependencies.
-
net8.0
- System.Threading.AccessControl (>= 8.0.0 && < 9.0.0)
-
net9.0
- System.Threading.AccessControl (>= 9.0.15 && < 10.0.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Enhanced STA task execution and COM interop support.