AdaskoTheBeAsT.Interop.Execution.DependencyInjection
1.0.0
dotnet add package AdaskoTheBeAsT.Interop.Execution.DependencyInjection --version 1.0.0
NuGet\Install-Package AdaskoTheBeAsT.Interop.Execution.DependencyInjection -Version 1.0.0
<PackageReference Include="AdaskoTheBeAsT.Interop.Execution.DependencyInjection" Version="1.0.0" />
<PackageVersion Include="AdaskoTheBeAsT.Interop.Execution.DependencyInjection" Version="1.0.0" />
<PackageReference Include="AdaskoTheBeAsT.Interop.Execution.DependencyInjection" />
paket add AdaskoTheBeAsT.Interop.Execution.DependencyInjection --version 1.0.0
#r "nuget: AdaskoTheBeAsT.Interop.Execution.DependencyInjection, 1.0.0"
#:package AdaskoTheBeAsT.Interop.Execution.DependencyInjection@1.0.0
#addin nuget:?package=AdaskoTheBeAsT.Interop.Execution.DependencyInjection&version=1.0.0
#tool nuget:?package=AdaskoTheBeAsT.Interop.Execution.DependencyInjection&version=1.0.0
AdaskoTheBeAsT.Interop
A focused interop toolbox for dedicated-thread execution, native library isolation, and COM-friendly workloads.
π¬ Code quality β SonarCloud
π Hello, interop friend
Interop code is fun, right up until it isn't. You know the signs:
- 𧬠a native library that secretly wants thread affinity
- π’ a COM component that quietly insists on STA + message pumping
- π§Έ an engine that loses its mind if two threads touch it at once
- β»οΈ a workload that needs explicit load / unload / recycle, not "hope the process survives"
AdaskoTheBeAsT.Interop.Execution is the reusable boilerplate you keep rewriting in every project: a dedicated worker thread (or a pool of them), a session it owns, a queue in front of it, and all the cancellation / disposal / telemetry plumbing that should just be a library by now. π¦
And now it is. β¨
β¨ Why you'll love this
- β‘ Zero-allocation hot path.
ExecuteValueAsyncis backed by pooledIValueTaskSource<T>on every TFM (yes, evennet462). NoTaskwrapping on the hot path. (ADR-0007) - π§© Drop-in DI + Hosting.
services.AddExecutionWorker<TSession>()+AddExecutionWorkerHostedService<TSession>()and you're done. - π« Pluggable schedulers.
LeastQueuedandRoundRobinship in the box; bring your own viaIWorkerScheduler<TSession>. (ADR-0002) - π Batteries-included observability.
ActivitySource+Meterwith public constant names, ready for OpenTelemetry. (ADR-0003) - πͺ First-class Windows STA. Flip a boolean, get an STA worker thread on Windows; silently ignored elsewhere.
- β»οΈ Real session recycling. After N operations, after a failure, or both β your call.
- π‘οΈ Terminal-once faulting. When a worker goes bad, it says so once, loudly, via
WorkerFaultedβ no silent-dead-thread surprises. - π₯οΈ 9 TFMs, all green.
net10.0,net9.0,net8.0,net481,net48,net472,net471,net47,net462β tested across the full matrix on every build. - βοΈ Source Link + snupkg. Step into the library from your debugger without guessing.
π¦ Packages
| Package | What it gives you |
|---|---|
AdaskoTheBeAsT.Interop.Execution |
β Core: ExecutionWorker<TSession>, ExecutionWorkerPool<TSession>, IExecutionSessionFactory<TSession>, options, schedulers, diagnostics. |
AdaskoTheBeAsT.Interop.Execution.DependencyInjection |
π§© Microsoft.Extensions.DependencyInjection helpers: AddExecutionWorker<TSession>() / AddExecutionWorkerPool<TSession>() with IOptions<T> binding. |
AdaskoTheBeAsT.Interop.Execution.Hosting |
ποΈ Microsoft.Extensions.Hosting integration: IHostedService wrappers driving worker / pool lifetime from the generic host. |
β¬οΈ Install
dotnet add package AdaskoTheBeAsT.Interop.Execution
dotnet add package AdaskoTheBeAsT.Interop.Execution.DependencyInjection
dotnet add package AdaskoTheBeAsT.Interop.Execution.Hosting
Symbols ship as .snupkg with Source Link and embedded untracked sources. Step in. Look around. It's fine.
πΊοΈ Target framework matrix
| TFM | Status | Notes |
|---|---|---|
net10.0 |
β | Primary target; in-box System.Threading.Channels + System.Diagnostics.DiagnosticSource. |
net9.0 |
β | Primary target. |
net8.0 |
β | Primary target. |
net481 |
β | Windows desktop; System.Threading.Channels + System.Diagnostics.DiagnosticSource via NuGet + IsExternalInit polyfill. |
net48 |
β | Same as above. |
net472 |
β | Same as above. |
net471 |
β | Same as above. |
net47 |
β | Same as above. |
net462 |
β | Same as above. |
Every cell is built with TreatWarningsAsErrors=true, ContinuousIntegrationBuild=true, Deterministic=true, and exercised in CI.
π‘ The core idea
Instead of every interop-heavy engine reinventing:
- a queue π
- a worker thread π§΅
- startup synchronisation π
- session lifetime β³
- disposal logic ποΈ
- failure / recycle behaviour β»οΈ
...you park that generic machinery in ExecutionWorker<TSession> or ExecutionWorkerPool<TSession>. Your engine becomes a thin adapter that answers three questions:
- π± How do I create a session?
- π₯ How do I dispose a session?
- π οΈ What work should run on that session?
That's it. The rest is the library's problem now.
ββββββββββββββββββββββββββββββββ
ExecuteAsync(x) βββΆ β Channel<ExecutionWorkItem> β
β (multi-writer, 1 reader) β
ββββββββββββββββ¬ββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββ
β Dedicated Thread β
β owns ONE TSession β βββ STA on Windows
β runs work in FIFO β if you ask
βββββββββββββ¬ββββββββββββ
β
βΌ
βββββββββββββββ
β TSession β (native libs, COM, ...)
βββββββββββββββ
π Main types
βοΈ ExecutionWorker<TSession>
A single dedicated background thread that owns one TSession. Submitted work runs sequentially in FIFO order. Implements IDisposable and IAsyncDisposable.
Owns π
- a multi-writer / single-reader
Channelof work items - one dedicated background
Thread(optionally STA on Windows) - startup / shutdown lifecycle (
InitializeAsync(CancellationToken)+ syncInitialize) - cooperative cancellation of pending items on shutdown
- session reuse + session recycle after failure or after N operations
- observability via
Name,IsFaulted,Fault,QueueDepth,WorkerFaulted, and the uniformGetSnapshot()
βοΈβοΈβοΈβοΈ ExecutionWorkerPool<TSession>
Fan-out pool of ExecutionWorker<TSession> instances. Each pool worker owns a private session and a private queue; a pluggable IWorkerScheduler<TSession> picks which worker receives each submission.
Owns π
- multiple
ExecutionWorker<TSession>instances - pluggable work distribution (see Scheduling below)
- one session per worker (ideal for isolated native DLL sets)
- per-worker isolation for native state
- parallel initialization and parallel disposal
- aggregate observability (
QueueDepth,IsAnyFaulted,WorkerFaults,Workers, forwardedWorkerFaulted, uniformGetSnapshot())
π IExecutionSessionFactory<TSession>
public interface IExecutionSessionFactory<TSession>
where TSession : class
{
TSession CreateSession(CancellationToken cancellationToken);
void DisposeSession(TSession session);
}
Creates the thread-affine session (loading native libs, initialising modules) and disposes / unloads it. Both methods run on the dedicated worker thread.
ποΈ ExecutionWorkerOptions
Name, UseStaThread, MaxOperationsPerSession (0 = unlimited), DisposeTimeout (default Timeout.InfiniteTimeSpan), Diagnostics (scoped ExecutionDiagnostics instance β defaults to a process-wide Shared singleton). Parameterless ctor + positional ctor + public setters so it binds cleanly via IOptions<T>.
ποΈ ExecutionWorkerPoolOptions
WorkerCount, Name, UseStaThread, MaxOperationsPerSession, DisposeTimeout, SchedulingStrategy (default LeastQueued), Diagnostics. Same binding story.
ποΈ ExecutionRequestOptions
Per-call knob: RecycleSessionOnFailure (default false).
πͺ STA behavior
If UseStaThread: true is set:
- β
On Windows, the worker thread is configured as
STAviaSetApartmentState(ApartmentState.STA)(guarded byOperatingSystem.IsWindows()onnet5+andPlatformID.Win32NTon older TFMs). - π€· On non-Windows, the flag is silently ignored.
That makes the option safe for cross-platform callers that want "STA when possible" behaviour.
π Quick example
using AdaskoTheBeAsT.Interop.Execution;
public sealed class NativeSession
{
public byte[] Render(string html) => []; // call into your native lib here
}
public sealed class NativeSessionFactory : IExecutionSessionFactory<NativeSession>
{
public NativeSession CreateSession(CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
return new NativeSession();
}
public void DisposeSession(NativeSession session)
{
// free native handles here
}
}
// 1. Spin up the worker.
await using var worker = new ExecutionWorker<NativeSession>(
new NativeSessionFactory(),
new ExecutionWorkerOptions(
name: "Native Render Worker",
useStaThread: true,
maxOperationsPerSession: 500));
await worker.InitializeAsync(cancellationToken);
// 2. Throw work at it. Returns when the work item completes.
byte[] bytes = await worker.ExecuteAsync(
(session, ct) => session.Render("<h1>Hello</h1>"),
new ExecutionRequestOptions(recycleSessionOnFailure: true),
cancellationToken);
β‘ Zero-alloc ValueTask hot path
When the caller is already in ValueTask land (e.g. wrapping a native-interop async API) the instance ExecuteValueAsync overloads keep you in that domain with pooled IValueTaskSource<T> work items β no inner Task allocation, on every TFM:
int sessionId = await worker.ExecuteValueAsync(
(session, _) => session.SessionId,
cancellationToken: cancellationToken);
See ADR-0007 for the gritty details; ADR-0004 is kept as historical context.
ππππ Pool: multiple workers
If one worker is not enough, use ExecutionWorkerPool<TSession>. Great fit when:
- π you have several native library copies in separate folders
- π¦ each worker should load its own isolated DLL set
- β»οΈ one failed worker session should recycle without touching the others
- ποΈ you want several dedicated threads, but still serialised execution per worker
await using var pool = new ExecutionWorkerPool<NativePoolSession>(
workerIndex => new NativePoolSessionFactory($@"c:\native\slot-{workerIndex + 1:D2}"),
new ExecutionWorkerPoolOptions(
workerCount: 4,
name: "Native Pool",
useStaThread: true,
maxOperationsPerSession: 250));
await pool.InitializeAsync(cancellationToken);
var result = await pool.ExecuteAsync(
(session, ct) => session.Render("<h1>Hello from pool</h1>"),
new ExecutionRequestOptions(recycleSessionOnFailure: true),
cancellationToken);
π‘ Tip: need every worker to share the exact same factory? There's a single-factory constructor overload too:
new ExecutionWorkerPool<T>(factory, options). Nice and tidy for stateless factories. (ADR-0008)
π¦ Scheduling
The pool ships with two built-in schedulers and a public IWorkerScheduler<TSession> seam if you need something bespoke.
| Built-in | Icon | Semantics |
|---|---|---|
LeastQueuedWorkerScheduler<TSession> (default) |
βοΈ | Picks the healthy worker with the smallest QueueDepth. Ties break via a shared rolling index so equal-depth workers rotate. Skips faulted workers. Early-exits when it finds a zero-depth worker. |
RoundRobinWorkerScheduler<TSession> |
π | Strict rotation across healthy workers via an Interlocked index. Skips faulted workers. |
Swap built-ins via options, or plug in a custom scheduler via the pool ctor:
// Option A: pick a built-in via options.
var opts = new ExecutionWorkerPoolOptions(
workerCount: 4,
schedulingStrategy: SchedulingStrategy.RoundRobin);
// Option B: inject a custom scheduler.
IWorkerScheduler<NativeSession> custom = new MyAffinityScheduler();
await using var pool = new ExecutionWorkerPool<NativeSession>(
workerIndex => new NativeSessionFactory(),
new ExecutionWorkerPoolOptions(workerCount: 4),
custom);
Rationale, trade-offs, and the faulted-worker contract are captured in ADR-0002.
π€ When to use what
1οΈβ£ Choose ExecutionWorker<TSession> when
- π the native engine is effectively process-global
- π₯΅ the library is known to be thread-sensitive
- β you want strict serialised access to one engine instance
- π you want exactly one owner thread
4οΈβ£ Choose ExecutionWorkerPool<TSession> when
- π€π€π€π€ you have isolated native copies per worker
- ποΈποΈ the library can run in parallel across separate worker-owned sessions
- π you want better throughput
- π§ you want one worker to recycle independently from the others
β»οΈ Session recycle story
You can choose to recycle the session:
- β after a failed request β
ExecutionRequestOptions.RecycleSessionOnFailure = true - π― after a fixed number of operations β
ExecutionWorkerOptions.MaxOperationsPerSession > 0 - β¨ or both
Set maxOperationsPerSession: 0 when you want unlimited session lifetime and only failure-based recycling.
π§© DI integration
using AdaskoTheBeAsT.Interop.Execution;
using AdaskoTheBeAsT.Interop.Execution.DependencyInjection;
services.AddSingleton<IExecutionSessionFactory<NativeSession>, NativeSessionFactory>();
services.AddExecutionWorker<NativeSession>(options =>
{
options.Name = "Native Render Worker";
options.UseStaThread = true;
options.MaxOperationsPerSession = 500;
});
// resolve IExecutionWorker<NativeSession> from DI and use it as usual
AddExecutionWorkerPool<TSession> is the pool-flavoured equivalent and binds IOptions<ExecutionWorkerPoolOptions>.
ποΈ Generic host integration
using AdaskoTheBeAsT.Interop.Execution.Hosting;
services.AddSingleton<IExecutionSessionFactory<NativeSession>, NativeSessionFactory>();
services.AddExecutionWorkerHostedService<NativeSession>(options =>
{
options.Name = "Native Render Worker";
options.UseStaThread = true;
});
The IHostedService wrappers drive InitializeAsync on StartAsync and DisposeAsync on StopAsync, idempotent against double-stop. AddExecutionWorkerPoolHostedService<TSession> covers the pool.
π Observability
Every worker emits to an ActivitySource and Meter named AdaskoTheBeAsT.Interop.Execution (customisable per worker via ExecutionWorkerOptions.Diagnostics β see ADR-0009 for scoped emitters).
| Instrument | Kind | Tags |
|---|---|---|
ExecutionWorker.Execute |
π Activity (span) |
worker.name |
execution.worker.operations |
π Counter<long> |
worker.name, outcome β success / faulted / cancelled |
execution.worker.session_recycles |
π Counter<long> |
worker.name, reason β max_operations / failure |
execution.worker.queue_depth |
π ObservableGauge<int> |
worker.name |
All these identifiers are exposed as public const string on ExecutionDiagnosticNames, so telemetry pipelines can subscribe without hard-coding strings:
using AdaskoTheBeAsT.Interop.Execution;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(ExecutionDiagnosticNames.SourceName))
.WithMetrics(m => m.AddMeter(ExecutionDiagnosticNames.SourceName));
When no listener is attached, StartActivity returns null and the instrumentation is allocation-free. β‘
See ADR-0003 for why the identifiers are a public contract.
β οΈ Faulting semantics
ExecutionWorker<TSession> is terminal-once: when a work item throws a non-cancellation exception during startup or session creation β or when DisposeSession throws during shutdown β _fatalFailure latches, IsFaulted flips to true, and WorkerFaulted fires exactly once. Subsequent ExecuteAsync calls rethrow the original exception synchronously (unwrapped via ExceptionDispatchInfo). The worker cannot be re-initialised after faulting.
Pool consumers observe the same contract aggregated: IsAnyFaulted, WorkerFaults, and a forwarded WorkerFaulted event carrying the originating worker name. π
π§ͺ Build and test
dotnet build .\AdaskoTheBeAsT.Interop.slnx
dotnet test .\AdaskoTheBeAsT.Interop.slnx --no-build
| Project | Role |
|---|---|
test/unit/AdaskoTheBeAsT.Interop.Execution.Test |
π¬ Unit + behavioural (fault propagation, dispose idempotency, cancellation, telemetry smoke, scheduler contract). |
test/unit/AdaskoTheBeAsT.Interop.Execution.DependencyInjection.Test |
π§© DI registration, options binding, lifetime. |
test/unit/AdaskoTheBeAsT.Interop.Execution.Hosting.Test |
ποΈ IHostedService start/stop lifecycle, idempotent shutdown. |
test/integ/AdaskoTheBeAsT.Interop.Execution.IntegrationTest |
π€ Multi-threaded submission, STA on Windows, reentrant dispose, session recycling, zero-alloc ValueTask, snapshot surface, scoped diagnostics. |
All four projects run across the full 9-target matrix.
π Architecture Decision Records
Small, self-contained design decisions taken on this codebase live under docs/adr/. Start with the index. Highlights:
- π§ ADR-0002 β pluggable worker scheduler
- π·οΈ ADR-0003 β public diagnostic constants
- β‘ ADR-0007 β zero-allocation
ExecuteValueAsync - πΈ ADR-0008 β uniform snapshot surface
- π ADR-0009 β scoped
ExecutionDiagnostics
π Contributing
Found a bug? Got an idea? Spotted a typo that's been haunting you? π»
- π Open an issue describing the problem or the proposal.
- π οΈ Fork + branch (
feature/your-idea). - β
Run
dotnet build+dotnet testacross the full matrix. - β¨ Add/update tests and an ADR if the change is load-bearing.
- π Open a PR β the strict-build + CI will do the rest.
π Further reading
- π
wkhtml.mdβ WkHtml migration notes. - π
docs/adr/β design rationale for every recent change. - π
CHANGELOG.mdβ what landed when.
<p align="center"> Built for the kind of interop code that likes <strong>one owner thread</strong>, <strong>explicit lifecycle</strong>, and <strong>zero drama</strong>. β¨<br/> Made with β€οΈ (and a lot of coffee β) by <a href="https://github.com/AdaskoTheBeAsT">AdaskoTheBeAsT</a>. </p>
| 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
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7.1
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.7.2
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.8
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
.NETFramework 4.8.1
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
net10.0
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.6 && < 11.0.0)
- Microsoft.Extensions.Options (>= 10.0.6 && < 11.0.0)
-
net8.0
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.2 && < 9.0.0)
- Microsoft.Extensions.Options (>= 8.0.2 && < 9.0.0)
-
net9.0
- AdaskoTheBeAsT.Interop.Execution (>= 1.0.0)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 9.0.15 && < 10.0.0)
- Microsoft.Extensions.Options (>= 9.0.15 && < 10.0.0)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on AdaskoTheBeAsT.Interop.Execution.DependencyInjection:
| Package | Downloads |
|---|---|
|
AdaskoTheBeAsT.WkHtmlToX.DependencyInjection
Microsoft.Extensions.DependencyInjection helpers for AdaskoTheBeAsT.WkHtmlToX: AddWkHtmlToX() registers the engine, converters and the underlying execution worker as singletons without hooking into IHostedService. |
|
|
AdaskoTheBeAsT.WkHtmlToX.Hosting
Microsoft.Extensions.Hosting integration for AdaskoTheBeAsT.WkHtmlToX: AddWkHtmlToXHostedService() registers an IHostedService that drives the WkHtmlToX engine worker lifecycle through the generic host. |
|
|
AdaskoTheBeAsT.Interop.Execution.Hosting
Microsoft.Extensions.Hosting integration for AdaskoTheBeAsT.Interop.Execution: IHostedService wrappers that drive ExecutionWorker / ExecutionWorkerPool startup and graceful drain through the generic host lifecycle. Targets net10.0, net9.0, net8.0, and net481/net48/net472/net471/net47/net462. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0 | 195 | 4/19/2026 |
## [Unreleased]
### Added
#### `AdaskoTheBeAsT.Interop.Execution` (core)
- **Zero-allocation `ExecuteValueAsync` hot path on every supported TFM.**
`ExecutionWorker<TSession>` and `ExecutionWorkerPool<TSession>` expose
instance `ExecuteValueAsync` overloads backed by pooled
`IValueTaskSource<TResult>` / `IValueTaskSource` work items
(`ManualResetValueTaskSourceCore<T>`). The `IValueTaskSource` primitives
ship natively from `net8.0+` and are available on `net462`..`net481`
through the `System.Threading.Tasks.Extensions` NuGet facade (pulled
in transitively by `System.Threading.Channels`), so the same zero-alloc
code path runs on all nine TFMs β no `Task` β `ValueTask` wrapper
fallback anywhere, and no public `ExecutionWorkerValueTaskExtensions`
static class to duplicate the API surface.
See [`docs/adr/0007-zero-alloc-value-task-source.md`](docs/adr/0007-zero-alloc-value-task-source.md).
- **`ExecutionWorkerPool<TSession>` single-factory constructors.** Two new
overloads accept a single `IExecutionSessionFactory<TSession>` that is
shared across every pool worker, eliminating the `Func<int, factory>`
boilerplate at call sites that do not need per-worker isolation. The
original per-index factory constructors remain for callers that do.
See [`docs/adr/0008-uniform-snapshot-surface.md`](docs/adr/0008-uniform-snapshot-surface.md).
- **Unified `Name` / `GetSnapshot` observability surface** on both worker
and pool:
- `IExecutionWorker<TSession>.Name` and `IExecutionWorkerPool<TSession>.Name`;
- `ExecutionWorkerSnapshot` (public `readonly struct`) with `Name`,
`QueueDepth`, `IsFaulted`, `Fault`;
- `ExecutionWorkerPoolSnapshot` (public `readonly struct`) with `Name`,
`WorkerCount`, aggregate `QueueDepth`, `IsAnyFaulted`, and an
index-aligned `IReadOnlyList<ExecutionWorkerSnapshot> Workers`;
- `IExecutionWorker<TSession>.GetSnapshot()` and
`IExecutionWorkerPool<TSession>.GetSnapshot()` capture all fields in a
single call so dashboards cannot observe inconsistent mixes of values.
- **Scoped `ExecutionDiagnostics`** public class replaces the previous
process-wide static diagnostics class. `ExecutionDiagnostics.Shared` is a
lazy singleton that continues to emit under
`ExecutionDiagnosticNames.SourceName`, so every existing OpenTelemetry /
`MeterListener` / `ActivityListener` subscriber by that name is unaffected.
New overloads on both `ExecutionWorkerOptions` and
`ExecutionWorkerPoolOptions` accept a custom `ExecutionDiagnostics` scope;
workers wired to a custom scope do not contribute measurements to
`Shared`, removing the parallel-test-host race on the previously shared
`Counter<long>` / `ObservableGauge<int>` state.
See [`docs/adr/0009-scoped-execution-diagnostics.md`](docs/adr/0009-scoped-execution-diagnostics.md).
### Quality
- **SonarCloud PR sweep (round 2) β 29 additional INFO-severity findings
addressed.** Modern-BCL suggestions that target `net6.0+` / `net7.0+` /
`net8.0+` are adopted on the matching TFMs via `#if` preprocessor
guards while legacy `net462`..`net481` keeps the explicit-throw fallback,
so nothing is regressed on older frameworks:
- **`CA1510` (13 findings).** `ArgumentNullException.ThrowIfNull` on
`net8.0+`, explicit `throw new ArgumentNullException(nameof(x))` on
`net462`..`net7.0` in `ExecutionWorker`, `ExecutionWorkerPool`,
`ExecutionDiagnostics`, `RoundRobinWorkerScheduler`,
`LeastQueuedWorkerScheduler`, `ExecutionWorkerServiceCollectionExtensions`,
`ExecutionWorkerHostingExtensions`.
- **`CA1513` (2 findings).** `ObjectDisposedException.ThrowIf` on
`net7.0+`, explicit `throw new ObjectDisposedException(TypeName)`
otherwise β `ExecutionWorker.ThrowIfDisposed`,
`ExecutionWorkerPool.ThrowIfDisposed`.
- **`CA2263` (1 finding).** `Enum.IsDefined<TEnum>(TEnum)` generic
overload on `net5.0+`, non-generic `Enum.IsDefined(Type, object)`
otherwise β `ExecutionWorkerPoolOptions` validation.
- **`MA0042` / `MA0040` (src β 2 findings).** Extended existing
`VSTHRD002` / `VSTHRD103` scoped pragmas in `ExecutionWorker` to
cover the related Meziantou cancellation rules, with the reasoning
comments expanded to document why the sync `Cancel()` and the
no-token `disposeTask.Wait(timeout)` are the correct primitives
inside the non-async `DisposeAsync` / sync-disposal paths.
- **Test-side `MA0040` (7 findings).** Passed
`TestContext.Current.CancellationToken` (xUnit v3, `net8.0+`) or
`CancellationToken.None` (xUnit v2, legacy TFMs) through a
preprocessor-guarded local to the relevant `ExecuteAsync` /
`ExecuteValueAsync` calls in `ExecutionWorkerTest`. The two
`ManualResetEventSlim.Wait(TimeSpan)` sites inside a session-factory
delegate in `ExecutionWorkerPoolTest` stay under a scoped
`MA0040` pragma because the callback signature has no token.
- **Test-side `MA0042` / `RCS1261` (3 findings).** The two intentionally
synchronous `Fact` methods in `ExecutionWorkerPoolTest`
(`IsAnyFaulted_ShouldBeFalseForHealthyPool`,
`SetWorkerThreadForTesting_ShouldThrowWhenWorkerThreadIsNull`) and
the single sync assertion block in `ExecutionWorkerTest`
(`InitializeAsync_ShouldReturnCancelledTaskForPreCancelledTokenAsync`)
keep `using var` + `Cancel()` under scoped pragmas because
`await using` / `CancelAsync` would force them to become async and
invalidate the sync-return assertions.
- **`IDE0350` (2 findings).** Two `ActivityListener.Sample` ref-lambdas
in `ExecutionWorkerTest` stay under a scoped pragma because the
analyzer's suggested simplification cannot compile against the
`SampleActivity` delegate signature (`ref ActivityCreationOptions<ActivityContext>`).
- **SonarCloud PR sweep β 123 INFO-severity findings addressed.** All
open code-smells reported on the pull request were either fixed or
intentionally suppressed with explanatory comments. Highlights:
- **Async-dispose preference (60 findings: `MA0042` + `RCS1261`).**
Converted `using var worker = new ExecutionWorker<T>(...)` and
`using var workerPool = new ExecutionWorkerPool<T>(...)` to
`await using` in every async test method. The two intentional
`Dispose()` timeout tests keep sync disposal under file-scoped
pragmas (`MA0042`, `RCS1261`, `IDISPxxx`, `VSTHRD103`, `S6966`,
`S125`) because they specifically exercise the synchronous
timeout branch.
- **Primary constructors (`IDE0290`, 11 findings).** `ExecutionWorkerHostedService`,
`ExecutionWorkerPoolHostedService`, `ExecutionWorker.ExecutionWorkItem<T>`
nested class, `ExecutionWorkerRegistration`, `ExecutionWorkerSnapshot`,
`WorkerFaultedEventArgs`, plus test sessions (`IntegrationSession`,
`DiTestSession`, `DiSecondTestSession`, `HostedTestSession`,
`MeterSnapshot.RecordedMeasurement`) all moved to primary
constructors with field-style initializers that preserve every
`ArgumentNullException` guard.
- **Auto-properties (`RCS1085`, 3 findings).** `ExecutionDiagnostics`
converted `_activitySource`, `_operationsCounter`, and
`_sessionRecyclesCounter` private fields into auto-implemented
properties.
- **FluentAssertions style (`FAA0001`, 9 findings).** Replaced
`result.Should().BeGreaterThan(0)` with `result.Should().BePositive()`
and replaced `observedSessionIds[i].Should().Be(x)` with
`observedSessionIds.Should().HaveElementAt(i, x)`.
- **Collection initializers (`IDE0028` / `IDE0300` / `IDE0301`,
8 findings).** `Array.Empty<T>()` and explicit `new[] { ... }`
occurrences in scheduler tests, `ExecutionWorkerPoolSnapshot`'s
`EmptyWorkers` field, and `MeterSnapshot._measurements` switched
to the `[]` collection-expression syntax.
- **`ValueTask` discards (`CA2012`, 5 findings).** Pragma-suppressed
around the explicit-throw assertions in
`ExecuteValueAsync_ShouldThrowWhenActionDelegateIsNull`,
`ExecuteValueAsync_ShouldThrowObjectDisposedExceptionAfterDispose`,
and the foreign-scheduler / null-scheduler pool tests, where
the test contract is that the call throws synchronously and
no usable `ValueTask` is ever produced.
- **Named arguments (`MA0003`, 3 findings).** `throw new ArgumentException(...)`
in `ExecutionWorker.Process`, `new ExecutionWorkerSnapshot(...)`
in scheduler test stubs, and `new ManualResetEventSlim(false)`
in dispose-timeout tests now spell parameters by name.
- **Polyfill in canonical namespace (`IDE0130`, `MA0182`, `RCS1251`).**
`Polyfills/IsExternalInit.cs` MUST live in
`System.Runtime.CompilerServices` for the C# compiler to
recognize it as the init-only marker on legacy `net46x` / `net47x` /
`net48x` TFMs; the three rules are pragma-suppressed at file
scope with an inline justification comment.
- **Documentation langword (`MA0154`, 2 findings).** `ExecutionWorkerOptions`
and `ExecutionWorkerPoolOptions` use `<see langword="new"/>`
instead of `<c>new</c>` in their summaries.
- **Misc fixes:** unused `workerIndex` parameter in
`ExecutionWorkerServiceCollectionExtensions` lambda renamed to
`_` (`RCS1163`); cancellation overload added to
`TaskCompletionSource.TrySetCanceled` in `ExecutionHelpers`
polyfill (`MA0040`); culture-invariant `int.ToString` in
interpolated assertion message (`MA0076`); `if/else if` ladder
in `Initialize_ShouldDisposeAlreadyStartedWorkers...` test
rewritten as a `switch` (`CC0019`); single-statement lambdas in
multi-threaded / value-task tests collapsed to expression-bodied
form (`RCS1021`); `IDE0042` deconstructed `requiredTags` foreach
in `MeterSnapshot.MatchesTags`; `RCS1118` upgraded
`expectedSessionCount` to a `const` in
`SessionRecyclingExecutionWorkerTest`; `RCS1205` reordered
named arguments in `MultiThreadedExecutionWorkerTest`; `CA1806`
discarded `new ExecutionDiagnostics(...)` in
`ScopedDiagnosticsTest` lambdas (with `IDISP004` co-suppressed
because the constructor throws before producing a disposable).
- **Quality Gate: OK.** All 6 conditions passed; 100 % new-code
coverage; A ratings on Reliability, Maintainability, and Security.
### Tests
- **Coverage raised further from ~92% to ~96.5% overall** by adding a second
round of 17 tests targeting the remaining defensive branches:
- `ExecutionWorker.InitializeAsync` pre-cancelled token fast-path
(`Task.FromCanceled`).
- `ExecutionWorker.SetFatalFailure` double-fire idempotency β the
`WorkerFaulted` event raises exactly once thanks to the CAS in
`RaiseFaultedOnce`.
- `ExecutionWorker.ProcessWorkItem` success + failure paths under a
registered `ActivityListener` (both `Activity.Status = Ok` and
`Activity.Status = Error` branches of the `SetStatus` calls).
- Synchronous `ExecutionWorker.Dispose` + `ExecutionWorkerPool.Dispose`
timeout branches: a blocking `IExecutionSessionFactory` wedges the
worker thread; `Dispose` abandons the wait after
`DisposeTimeout` without throwing.
- `ExecutionWorkerPool.IsAnyFaulted` returns `false` for a healthy pool
(post-loop `return false` branch).
- `ExecutionWorkerPool.SetWorkerThreadForTesting` argument-null guard.
- Pool `ExecuteAsync` and `ExecuteValueAsync` both throw
`InvalidOperationException("The worker scheduler returned null.")`
when a custom scheduler violates the contract.
- Direct unit tests on internal pooled work items covering:
explicit non-generic `IValueTaskSource` interface on
`PooledValueExecutionWorkItem<,>` (`GetStatus` / `OnCompleted` /
`GetResult`), `TrySetCanceled` β cancelled `ValueTask(<T>)`, the
stale-token guard in `GetResult` (`token != Version` mismatch), the
defensive `_action is null` branch (rented with a null delegate),
and the `Options` + `CancellationToken` property accessors.
- **Coverage raised from ~86% to ~92% overall** by adding ~45 focused tests:
- `TrivialCoverageTest` and `DiagnosticsAndHelpersTest` cover all the
small defensive branches in `ExecutionWorkerOptions` /
`ExecutionWorkerPoolOptions` (negative `DisposeTimeout`, undefined
`SchedulingStrategy`, fresh-instance `Default`),
`WorkerFaultedEventArgs` (null-exception guard), `ExecutionHelpers`
(null-action guard), `ExecutionWorkerRegistration` (null-accessor
guard), `ExecutionDiagnostics` (null/whitespace source name,
idempotent Dispose, no-op Dispose on `Shared`, null-registration
guards), `ExecutionWorkerPoolSnapshot` (null-workers fallback,
aggregation), and both `RoundRobinWorkerScheduler` /
`LeastQueuedWorkerScheduler` (null / empty / single-worker /
all-faulted / healthy-lowest-depth paths).
- New `ExecutionWorkerHostingExtensionsTest` covers the two
`services is null` guards.
- New null / pre-cancelled / post-dispose / channel-closed tests for
`ExecutionWorker.ExecuteValueAsync` (void + `TResult`).
- Pool-level tests now exercise the shared-factory constructor
(with and without an explicit scheduler), `ExecuteValueAsync` void
and `TResult` routing through `SelectConcreteWorker`, and the
foreign-worker `InvalidOperationException` branch via a custom
`IExecutionWorker<T>` stub that is deliberately not an
`ExecutionWorker<T>`.
- DI-level tests now cover the `IOptionsFactory<T>` fallback branch
(by removing the open-generic `IOptionsMonitor<>` descriptor
before building the provider), the unnamed-`IOptions<T>` fallback
when no configure delegate is supplied, and the
`ExecutionWorkerOptions.Default` fallback when nothing at all is
registered. Same coverage is added for the pool pipeline.
### Removed
#### `AdaskoTheBeAsT.Interop.Execution` (core)
- **`ExecutionWorkerValueTaskExtensions` public static class** β deleted.
Its only job was to dispatch `.ExecuteValueAsync(...)` calls on
`IExecutionWorker<T>` / `IExecutionWorkerPool<T>` to the concrete type's
instance method when on `net8.0+`, and on older TFMs to wrap
`ExecuteAsync(...)`'s `Task` in a `ValueTask` as a best-effort ergonomic
fallback (not zero-alloc). Now that the instance `ExecuteValueAsync`
overloads are unconditionally available on every TFM, the extension
added nothing but a second public-API surface and a `Task β ValueTask`
wrapper on the slow path for third-party `IExecutionWorker<T>`
implementations β callers can write `new ValueTask(worker.ExecuteAsync(...))`
themselves with identical semantics.
### Fixed
#### `AdaskoTheBeAsT.Interop.Execution` (core)
- **`WorkerFaulted` / `IsFaulted` publication ordering.** `ExecutionWorker<TSession>.SetFatalFailure`
now dispatches the `WorkerFaulted` event *before* writing the fault through
the volatile `_fatalFailure` field. This establishes a happens-before
chain so any observer that sees `IsFaulted == true` (or
`IsAnyFaulted == true` on a pool) has also seen every `WorkerFaulted`
subscriber complete. The previous ordering made it possible for a caller
polling `IsFaulted` to read `true` during the narrow window between the
volatile write and the event dispatch, and assert on subscriber state
that had not yet been produced. `WorkerFaultedEventArgs` still carries
the exception, so event handlers do not rely on `IsFaulted` / `Fault`
inside their own scope.
- **`ExecutionWorkerPool<TSession>.Dispose` / `DisposeAsync` now share a
single in-flight drain `Task`.** Previously a caller whose synchronous
`Dispose()` timed out via `DisposeTimeout` would have the `_asyncDisposed`
interlocked flag flipped to 1, so any subsequent `await pool.DisposeAsync()`
became an instant no-op even though the background drain was still
running. The pool now caches the dispose `Task` on first invocation
behind `_disposeLock`, and both the sync and async paths await the same
instance β callers who await `DisposeAsync()` after a timed-out
`Dispose()` correctly observe drain completion (or the fault) instead of
a silent early return.
- **`MeterSnapshot.Last()` determinism.** The test helper used a
`ConcurrentBag<RecordedMeasurement>` whose enumeration order is
thread-local-stack-based, not insertion-ordered. `Last()` therefore
returned whichever thread's local slot happened to be iterated last β
non-deterministic, and the root cause of intermittent flakes on
telemetry assertions under parallel test-host load. Replaced with a
coarse-locked `List<T>` whose reverse-iteration returns the strictly
last-inserted matching measurement.
- **`ExecutionWorkerOptions.Default` is no longer a mutable shared
singleton.** Changed from `static ExecutionWorkerOptions Default { get; } = new();`
to `static ExecutionWorkerOptions Default => new();` so every fallback
site (null-coalescing path in `ExecutionWorker` ctor; DI extension
fallback) receives a fresh instance. Prevents accidental global-state
poisoning via e.g. `ExecutionWorkerOptions.Default.DisposeTimeout = TimeSpan.Zero`.
- **NuGet package metadata mismatch.** All three packages'
`<Description>` strings, the README TFM matrix, and the DI-test
conditional `ItemGroup` still mentioned `netstandard2.0`, which was
dropped from `TargetFrameworks` earlier. Descriptions and metadata are
now aligned with the actual shipping 9-TFM matrix (`net10.0`,
`net9.0`, `net8.0`, `net481`, `net48`, `net472`, `net471`, `net47`,
`net462`).
- **DI: `AddExecutionWorker<TSession>` / `AddExecutionWorkerPool<TSession>`
no longer leak `configure` delegates across different `TSession` types.**
Previously the extension called `services.Configure(configure)` which
pushed the delegate into the single global
`IOptions<ExecutionWorkerOptions>` (or `...PoolOptions`) pipeline, so
multiple `AddExecutionWorker<T>(...)` calls for different `TSession`
types stacked their delegates and produced a single merged options
instance shared by every worker (last-wins on `Name` + union of the
other properties). The extension now registers a *named* options entry
keyed by `typeof(TSession).FullName`, and the factory resolves via
`IOptionsMonitor<T>.Get(name)` so each `TSession` binding receives its
own isolated options. Consumers who only call `AddExecutionWorker<T>`
once (the common case) observe no behavioural change; consumers who
pre-bind configuration to the unnamed `IOptions<T>` pipeline (i.e. do
not pass a `configure` delegate) continue to get the existing shared
behaviour. Two new regression tests
(`AddExecutionWorker_ShouldIsolateOptionsAcrossDifferentSessionTypesAsync`,
`AddExecutionWorkerPool_ShouldIsolateOptionsAcrossDifferentSessionTypesAsync`)
pin the isolation contract.