AdaskoTheBeAsT.Interop.Execution.DependencyInjection 2.1.0

dotnet add package AdaskoTheBeAsT.Interop.Execution.DependencyInjection --version 2.1.0
                    
NuGet\Install-Package AdaskoTheBeAsT.Interop.Execution.DependencyInjection -Version 2.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.Execution.DependencyInjection" Version="2.1.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="AdaskoTheBeAsT.Interop.Execution.DependencyInjection" Version="2.1.0" />
                    
Directory.Packages.props
<PackageReference Include="AdaskoTheBeAsT.Interop.Execution.DependencyInjection" />
                    
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.Execution.DependencyInjection --version 2.1.0
                    
#r "nuget: AdaskoTheBeAsT.Interop.Execution.DependencyInjection, 2.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.Execution.DependencyInjection@2.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.Execution.DependencyInjection&version=2.1.0
                    
Install as a Cake Addin
#tool nuget:?package=AdaskoTheBeAsT.Interop.Execution.DependencyInjection&version=2.1.0
                    
Install as a Cake Tool

AdaskoTheBeAsT.Interop

A focused interop toolbox for dedicated-thread execution, native library isolation, and COM-friendly workloads.

NuGet NuGet NuGet License: MIT TFMs Warnings Deterministic

πŸ”¬ 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, 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. ✨


Changelog

2.1.0 (Unreleased)

The version in Directory.Build.props is 2.1.0 for all three packages. This release updates dependencies and build tooling without changing the library's public APIs or supported target frameworks.

  • Updated Microsoft.Extensions dependency minimums to 10.0.12 for .NET 10 and .NET Framework, and 9.0.20 for .NET 9. .NET 8 dependencies are unchanged.
  • Updated System.Threading.Channels and System.Diagnostics.DiagnosticSource minimums to 10.0.12 for .NET Framework.
  • Updated the .NET SDK to 10.0.401, Meziantou.Analyzer to 3.0.231, and Microsoft.CodeAnalysis.NetAnalyzers to 10.0.401.
  • Aligned test-project dependencies with the updated package versions.

See the full changelog for release history.


Why upgrade to 2.0.0?

2.0.0 makes worker execution and shutdown more reliable and raises the minimum .NET Framework version to 4.7.2. Dropping net462, net47, and net471 is a breaking change and the reason for the major version. Existing integration APIs remain available on supported targets. The comparison below is against the tagged 1.0.0 release.

If your application... What could go wrong in 1.0.0 What 2.0.0 improves
Uses ExecuteValueAsync under load A caller could return a pooled source while the worker still used it; delegate failures bypassed worker recovery. Completion is the worker's last access to the item. Task and ValueTask requests share failure, cancellation, recycling, and outcome reporting.
Recycles native sessions A request could report success before required teardown finished. Awaiting the request includes recycle teardown. Cleanup failures become observable instead of looking like successful work.
Submits work before initialization finishes An async submission could block its calling thread on session creation. Submissions enqueue without waiting for creation and retain per-worker FIFO order. Startup failures settle the returned requests.
Shuts down from several places A repeated or reentrant disposal could make an external caller think teardown was already complete. Every external DisposeAsync() joins actual teardown, even after a synchronous disposal timeout.
Receives bursts of work Admission was unbounded. Opt into QueueCapacity to limit waiting requests and reject excess work explicitly, including during startup.
Needs a shutdown policy Pending-work handling was not an explicit choice. Choose Drain or CancelPending. Hosted shutdown also observes the host's cancellation deadline without canceling cleanup.
Relies on faults and tracing Fault handlers ran on the owning thread before Fault was published; listener errors could disrupt execution. Fault state is latched before off-thread notification. Listener exceptions are contained, and spans use the submitting request's activity context.
Reuses options or custom pool schedulers Later option mutations could alter live behavior; a scheduler could select a foreign concrete worker. Workers snapshot configuration, pools reject non-members, and partial pool construction rolls back earlier workers.

What is not new: pooled ExecuteValueAsync, DI/Hosting, session recycling, schedulers, snapshots, and scoped diagnostics already shipped in 1.0.0. This is a correctness and lifecycle upgrade, not a claim of faster native execution or a new benchmark result.

Before upgrading: applications targeting .NET Framework 4.6.2, 4.7, or 4.7.1 must retarget to 4.7.2 or later, or remain on 1.0.0. Modern .NET 8, 9, and 10 targets are unchanged.

On supported targets, existing public signatures are retained. The new options default to unlimited admission (QueueCapacity = 0) and Drain; applications receive the fixes without opting into queue limits. Completion timing, event ordering, and option validation do change, so review the 1.0.x to 2.0.0 migration guide before upgrading. See the changelog for release details.


✨ Why you'll love this

  • Pooled ValueTask path. ExecuteValueAsync reuses IValueTaskSource<T> work items on every supported TFM, including net472, avoiding a per-request source/Task allocation when a pooled item is available. This is not a guarantee of zero total allocations. (Completion contract)
  • DI + Hosting. Use AddExecutionWorker<TSession>() for plain DI, or AddExecutionWorkerHostedService<TSession>() to register both the worker and its hosted lifecycle.
  • πŸ’« Pluggable schedulers. LeastQueued and RoundRobin ship in the box; bring your own via IWorkerScheduler<TSession>. (ADR-0002)
  • πŸ”­ Batteries-included observability. ActivitySource + Meter with 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.
  • 6 target frameworks. net10.0, net9.0, net8.0, net481, net48, net472. .NET Framework 4.7.2 is the minimum for 2.0.0.
  • ✏️ 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.

This six-target matrix applies to all three packages in 2.0.0. Removed: net462 (.NET Framework 4.6.2), net47 (4.7), and net471 (4.7.1). Retarget affected projects and deployment environments to .NET Framework 4.7.2 or later before installing 2.0.0; see the migration guide.

CI enables TreatWarningsAsErrors=true, ContinuousIntegrationBuild=true, and Deterministic=true. All four test projects target this same matrix.


πŸ’‘ 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:

  1. 🌱 How do I create a session?
  2. πŸ₯€ How do I dispose a session?
  3. πŸ› οΈ 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 Channel of work items
  • one dedicated background Thread (optionally STA on Windows)
  • startup / shutdown lifecycle (InitializeAsync(CancellationToken) + sync Initialize)
  • configurable draining or 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 uniform GetSnapshot()

βš™οΈβš™οΈβš™οΈβš™οΈ 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)
  • separate worker-owned sessions (process-global native state still needs adapter-level isolation)
  • parallel initialization and parallel disposal
  • aggregate observability (QueueDepth, IsAnyFaulted, WorkerFaults, forwarded WorkerFaulted, and per-worker snapshots via GetSnapshot().Workers)

🏭 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>.

QueueCapacity limits requests waiting for startup or execution (0 = unlimited). ShutdownMode selects Drain (default) or CancelPending. Options are validated and copied when the worker is constructed; later mutations do not reconfigure it.

πŸŽ›οΈ ExecutionWorkerPoolOptions

WorkerCount, Name, UseStaThread, MaxOperationsPerSession, DisposeTimeout, SchedulingStrategy (default LeastQueued), Diagnostics. Same binding story.

QueueCapacity and ShutdownMode apply to each worker. Pool options are also copied at construction. Capacity is per worker, not a shared pool-wide limit.

πŸŽ›οΈ ExecutionRequestOptions

Per-call knob: RecycleSessionOnFailure (default false).


πŸͺŸ STA behavior

If UseStaThread: true is set:

  • βœ… On Windows, the worker thread is configured as STA via SetApartmentState(ApartmentState.STA) (guarded by OperatingSystem.IsWindows() on net5+ and PlatformID.Win32NT on 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.

The worker does not run a COM or UI message pump. STA alone is insufficient for components that require one. Separate worker sessions also do not isolate process-global native state; the adapter must supply any required process-wide serialization or process isolation.

Startup, admission, cancellation, and shutdown

  • Submit synchronous delegates only. An async lambda can escape the owning thread and outlive its session. Do not synchronously wait for nested work on the same worker.
  • Async submissions return without waiting for initial session creation. Work is queued in FIFO order on each worker, including during startup.
  • InitializeAsync(token) cancels only that caller's wait. Shared startup continues for other callers. Disposing the worker separately requests cancellation of initial session creation.
  • A full QueueCapacity faults the submission with InvalidOperationException; it does not block or silently drop work. The executing request is excluded from the limit. A pool does not retry another worker after capacity rejection.
  • A request canceled before execution is skipped when dequeued. Once running, its delegate must observe its own token. Neither request cancellation nor shutdown forcibly interrupts managed or native code.
  • Drain completes queued work on an available session. CancelPending skips requests not yet started. Both let running delegates finish before teardown. If initial creation is canceled or fails, queued requests cannot run and are completed with cancellation or failure.
  • Every external DisposeAsync() joins the same actual teardown. A call from the owning worker only requests shutdown, avoiding a self-deadlock. Synchronous Dispose() can abandon its wait at DisposeTimeout; cleanup continues and a later DisposeAsync() still joins it.
  • Hosted StopAsync(token) uses the host deadline to bound its wait, not the cleanup itself. If the token fires before teardown completes, awaiting StopAsync throws OperationCanceledException. Container disposal may still wait for a blocked native call.

Hard deadlines and recovery from a hung native call require a separate process. See ADR-0010 for the ownership and compatibility decisions.


πŸš€ Quick example

This complete console example uses top-level statements on modern .NET. The session is a stub; replace Render and the factory cleanup with your native API.

using System.Text;
using AdaskoTheBeAsT.Interop.Execution;

CancellationToken cancellationToken = CancellationToken.None;

// 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) =>
    {
        ct.ThrowIfCancellationRequested();
        return session.Render("<h1>Hello</h1>");
    },
    new ExecutionRequestOptions(recycleSessionOnFailure: true),
    cancellationToken);

Console.WriteLine($"Produced {bytes.Length} bytes.");

public sealed class NativeSession
{
    public byte[] Render(string html) => Encoding.UTF8.GetBytes(html);
}

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, on the owning worker thread.
    }
}

Pooled ValueTask hot path

The concrete ExecutionWorker<TSession> and ExecutionWorkerPool<TSession> types expose instance ExecuteValueAsync overloads backed by pooled IValueTaskSource<T> work items on every supported TFM. Use the same synchronous delegate as with ExecuteAsync; only the returned completion type changes. The IExecutionWorker<TSession> and IExecutionWorkerPool<TSession> interfaces expose ExecuteAsync, not ExecuteValueAsync. Keep using ExecuteAsync when consuming those interfaces through DI.

byte[] bytes = await worker.ExecuteValueAsync(
    (session, ct) =>
    {
        ct.ThrowIfCancellationRequested();
        return session.Render("<h1>Hello</h1>");
    },
    cancellationToken: cancellationToken);

Await each returned ValueTask exactly once. If you need to share or repeatedly await a result, use ExecuteAsync, or call AsTask() once and retain that Task. Do not discard, double-await, or synchronously read an incomplete ValueTask.

Pooling avoids per-request source/Task allocations when an item can be reused; startup, pool misses, delegates, channels, continuations, and tracing can still allocate. The pooling API already existed in 1.0.0. The 2.0.0 improvement is safe completion ownership and the same recovery behavior as the Task path. See ADR-0010, which supersedes the lifecycle design in ADR-0007.


🏭🏭🏭🏭 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.

Task and ValueTask calls use the same failure and recycle policy. Completion is published only after any required session teardown. A teardown error after a successful delegate faults the request and worker. If the delegate also failed, its exception remains the request failure and Fault records the terminal teardown error. A canceled request does not count as a successful operation or trigger 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 registration includes the worker and its IHostedService wrapper; you do not need a separate AddExecutionWorker<TSession>() call. The wrapper drives InitializeAsync on StartAsync and joins DisposeAsync on StopAsync. In 2.0.0, a canceled stop token cancels only that wait, not worker cleanup. Repeated stops join the same teardown, subject to each caller's token. 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 activity listener is attached, StartActivity returns null and no execution span is created. This does not guarantee allocation-free execution.

See ADR-0003 for why the identifiers are a public contract.


⚠️ Faulting semantics

ExecutionWorker<TSession> is terminal-once: a session creation or teardown failure latches Fault before WorkerFaulted is queued to the thread pool. Subscribers run off the owning thread; throwing subscribers are contained. Notification is asynchronous and may arrive after disposal completes. The first terminal exception wins, and the worker cannot be re-initialised.

A delegate exception alone faults that request, optionally recycling its session. An OperationCanceledException is cancellation only when the request token was canceled; otherwise it is a failure for both Task and ValueTask calls. Future submissions rethrow a terminal fault synchronously unless disposal has already been requested, in which case they throw ObjectDisposedException.

Disposal joins teardown without rethrowing terminal session errors. Inspect Fault, IsFaulted, or WorkerFaulted for those errors. Diagnostic-listener exceptions do not fail requests; execution spans use the submitting activity as their parent.

Pool consumers observe the same contract aggregated: IsAnyFaulted, WorkerFaults, and a forwarded WorkerFaulted event carrying the originating worker name. πŸ””


πŸ” Migrating from 1.0.x

Start with the 2.0.0 migration guide. It covers retargeting, package updates, before/after behavior, copyable option examples, and an upgrade checklist.

  • Retarget .NET Framework 4.6.2, 4.7, and 4.7.1 projects to 4.7.2 or later before upgrading. If you cannot retarget, remain on 1.0.0.
  • On supported targets, keep your existing factory, worker/pool, and DI/Hosting APIs. Public signatures are retained from tagged 1.0.0, but framework support is not backward-compatible.
  • Review request completion, fault-event ordering, cancellation, and repeated disposal. API compatibility does not mean identical runtime behavior.
  • Configure options before constructing or resolving a worker. They are now snapshotted, and DisposeTimeout values above int.MaxValue milliseconds are rejected during validation.
  • Opt into QueueCapacity or CancelPending only if your application needs them. Handle rejection/cancellation explicitly.
  • Do not use a timeout as proof that a native call stopped. Only completed external DisposeAsync() confirms teardown; inspect Fault separately.

πŸ§ͺ 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 test projects target the same six-framework matrix as the packages.


πŸ“œ Architecture Decision Records

Small, self-contained design decisions taken on this codebase live under docs/adr/. Start with the index. Highlights:


πŸ™‹ Contributing

Found a bug? Got an idea? Spotted a typo that's been haunting you? πŸ‘»

  1. πŸ™ Open an issue describing the problem or the proposal.
  2. πŸ› οΈ Fork + branch (feature/your-idea).
  3. βœ… Run dotnet build + dotnet test across the full matrix.
  4. ✨ Add/update tests and an ADR if the change is load-bearing.
  5. πŸš€ Open a PR β€” the strict-build + CI will do the rest.

πŸ“š Further reading


<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 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 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 (3)

Showing the top 3 NuGet packages that depend on AdaskoTheBeAsT.Interop.Execution.DependencyInjection:

Package Downloads
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.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.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

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
2.1.0 75 9/9/2026
2.0.0 119 9/7/2026
1.0.0 287 4/19/2026

## [2.1.0] - Unreleased

A dependency and build-tooling update for all three packages. Public APIs and
the six-target framework matrix are unchanged from 2.0.0.

### Changed

- Raised Microsoft.Extensions dependency minimums in the DependencyInjection
 and Hosting packages from `10.0.11` to `10.0.12` for .NET 10 and .NET Framework,
 and from `9.0.19` to `9.0.20` for .NET 9. Existing upper bounds and .NET 8
 dependencies are unchanged.
- Raised `System.Threading.Channels` and `System.Diagnostics.DiagnosticSource`
 dependency minimums from `10.0.11` to `10.0.12` for .NET Framework in the core
 package, retaining the existing upper bounds.
- Aligned test-project dependency versions with the package updates.
- Updated the .NET SDK in `global.json` from `10.0.400` to `10.0.401`.
- Updated `Meziantou.Analyzer` from `3.0.217` to `3.0.231` and
 `Microsoft.CodeAnalysis.NetAnalyzers` from `10.0.400` to `10.0.401`.