Skip to content

In-Process DotNet DLL Workers (default)

The WorkManager supports running pre-compiled .NET DLL workers directly in-process via the in-process engine (Virtufin.WorkManager.Engine.DotNetDll.DotNetDllEngine). This is the default engine for the application/x-dotnet-dll MIME type as of LIBRARY_VERSION 0.0.59.

Architecture

The engine has two paths, selected automatically by whether a JIT runtime is already resident (HostFxrBootstrap.IsCoreClrLoaded()):

  • JIT host (dev dotnet run, dotnet test): the worker DLL is loaded directly into a collectible WorkerLoadContext in-process and dispatched with no serialization.
  • NativeAOT host (the published WorkManager): the AOT runtime has no JIT, so AssemblyLoadContext.LoadFromStream throws PlatformNotSupportedException. The engine instead embeds a real CoreCLR via libhostfxr and loads the bridge assembly (Virtufin.WorkManager.Engine.DotNetDll.Bridge) into it through hostfxr_get_runtime_delegate(load_assembly_and_get_function_pointer). The bridge (plain IL, running under CoreCLR) does the actual AssemblyLoadContext.LoadFromStream + IWorker discovery and dispatch. CloudEvents cross the C-ABI boundary as UTF-8 JSON.
┌─────────────────────────────────────────────────────────────┐
│  WorkManager (AOT-compiled native binary)                    │
│                                                              │
│  ┌──────────────────┐        ┌───────────────────────────┐  │
│  │ DotNetDllEngine  │        │  CoreCLR (embedded via     │  │
│  │ (AOT code)       │        │  libhostfxr on first use)  │  │
│  │                  │  C ABI │                            │  │
│  │  LoadCodeAsync   │──fnptr─▶  WorkerBridge.LoadWorker   │  │
│  │  ProcessAsync    │──fnptr─▶  WorkerBridge.ProcessEvent │  │
│  │  (CloudEvent as  │        │   └─▶ WorkerLoadContext +   │  │
│  │   UTF-8 JSON)    │◀─bytes─┤        IWorker (JIT)        │  │
│  └──────────────────┘        └───────────────────────────┘  │
│                                                              │
│  The bridge assembly ships framework-dependent at            │
│  <app>/bridge; it is NOT linked into the AOT host.           │
└─────────────────────────────────────────────────────────────┘

Under a JIT host the bridge is skipped and DotNetDllEngine calls the worker directly in-process (the diagram's right-hand box is the host's own runtime).

What you get

  • Low per-call latency. Under JIT, a direct in-process method dispatch. Under AOT, an in-process C-ABI call into the embedded CoreCLR with a JSON round-trip (no socket, no subprocess).
  • Shared runtime. All in-process workers share one CoreCLR instance. A WM with 10 in-process managed workers uses ~50-80 MB of working set, vs. ~300-500 MB for 10 separate subprocesses (one per worker).
  • No subprocess lifecycle. No health monitor, no socket PING/PONG, no restart on subprocess exit. The engine's LoadCodeAsync is the lifecycle boundary.
  • Lazy CoreCLR init. The .NET runtime is loaded into the WM process on the first LoadCodeAsync, not at WM startup. The cold-start cost (~200-500 ms one-time) is paid then.

What you give up

  • In-process fault isolation. A managed worker that throws StackOverflowException, OutOfMemoryException, or P/Invokes into native code that corrupts the process can take down the entire WorkManager. AssemblyLoadContext provides assembly isolation, not process isolation. Workers that need process-level isolation should opt back into the out-of-process engine (see below).
  • AOT purity, in practice. The engine code is AOT-compiled, but the WM process now contains a CoreCLR. Reverse-engineers will see a .NET runtime in the WM's memory; the on-disk WM binary is still AOT, but the process is mixed.

Setup

The WM looks for libhostfxr (hostfxr.dll, libhostfxr.so, or libhostfxr.dylib depending on OS) on the host. The runtime can be located via any of:

  • System install. A .NET runtime installed in the standard location (e.g. C:\Program Files\dotnet on Windows, /usr/share/dotnet on Linux, /usr/local/dotnet on macOS).
  • DOTNET_ROOT environment variable. Set this to the directory containing the .NET runtime. HostFxrBootstrap resolves libhostfxr under $DOTNET_ROOT/host/fxr/<version>/.
  • Side-by-side. Place a host/fxr/<version>/ directory next to the WM binary.

Docker image. The WM image is based on mcr.microsoft.com/dotnet/runtime:<ver>-noble-chiseled (not runtime-deps), so the shared framework and libhostfxr ship in the image at /usr/share/dotnet, and DOTNET_ROOT is set to that path. runtime-deps has no runtime and cannot load DLL workers.

The generated Virtufin.WorkManager.Engine.DotNetDll.runtimeconfig.json declares "rollForward": "Major", so a .NET 11+ runtime on the host can satisfy this .NET 10 build without rebuilding the WM.

If the runtime is not findable, the first LoadCodeAsync call fails with a clear error message. The WM itself starts fine without a runtime — the cost is paid only when a managed worker is first loaded.

Worker contract

The same Virtufin.Worker.DevKit.IWorker interface used by all other engines. No changes needed for existing workers.

Worker packaging

The same .nupkg layout used by the out-of-process engine:

worker.nupkg/
├── <id>.nuspec                       # declares id, <dependencies>, virtufin* extensions
└── lib/
    └── <tfm>/
        └── <id>.dll                  # the worker assembly ONLY -- no vendored deps

Third-party dependencies are declared in the nuspec's <dependencies> element (from your PackageReferences -- dotnet pack does this automatically), not vendored into lib/<tfm>/. NuGetDependencyInstaller resolves and downloads them at LoadWorker time; the resulting DLLs are merged into the same sibling-dependency dictionary the canonical-assembly invariant below already describes, so nothing else about that mechanism changes. The <virtufinLibrary> extension element in the nuspec must match the worker DLL's basename (without extension). See dotnet-dll-workers.md for the full nupkg format and dependency-declaration guidance.

Canonical-assembly invariant

The in-process engine relies on a load-order contract for type identity. For every assembly the worker DLL references:

  1. The WorkManager host process loads its own copy of the assembly into the default AssemblyLoadContext (because the WM's own code references it, e.g. CloudNative.CloudEvents in WorkerBase.BuildResponse, Google.Protobuf in gRPC stubs).
  2. The worker ALC's Load(AssemblyName) is invoked for the same assembly reference.
  3. If the simple name matches an assembly the host already loaded, the host's instance is returned. This guarantees that the worker's CloudEvent parameter (worker ALC) and the CommandWorker<T>.HandleCommandAsync abstract method's CloudEvent parameter (default ALC, via DevKit aliasing) are the same Type instance. The override binds correctly.

If the host doesn't have an assembly of the given name, the worker ALC falls back to the nupkg's sibling DLLs (e.g. a worker-private helper assembly). This path produces a separate Type instance in the worker ALC — fine for worker-local types, but the worker must not depend on these being identical to anything in the host.

Practical implication for worker authors: declare every third-party assembly your worker needs as a PackageReference (so it ends up in the nuspec's <dependencies>) — don't vendor DLLs into the nupkg. NuGetDependencyInstaller resolves and downloads them at load time, filling the same "sibling DLLs" role a vendored nupkg used to. The host will alias the ones it already has (DevKit, CloudNative.CloudEvents); the rest load from the freshly-downloaded bytes. Don't assume the host has an assembly you didn't declare — the worker ALC will fail to load and you'll see a FileNotFoundException in the loader diagnostics.

Loader-error surfacing

When a worker assembly can't be loaded, the engine throws Virtufin.WorkManager.Engine.DotNetDll.InvalidWorkerException whose message lists every loader error from ReflectionTypeLoadException.LoaderExceptions. The InnerException is the original ReflectionTypeLoadException for programmatic inspection. Common failures you'll see:

  • Could not load file or assembly 'X' — a referenced assembly is missing from both the host and the nupkg's resolved dependencies. Add it as a PackageReference in your worker's .csproj so it ends up in the nuspec's <dependencies> (don't vendor the DLL directly).
  • Method 'HandleAsync' ... does not have an implementation — pre-0.0.60 failure mode. Fixed in 0.0.60 by the canonical- assembly invariant. If you see this on 0.0.60+, your worker is referencing a Type from a sibling-only assembly in an override signature; bundle that assembly in the host too (or restructure the override to use BCL types only).

Caveats and known issues

  • Thread-static state: AssemblyLoadContext reload does not preserve [ThreadStatic] state across LoadCodeAsync calls. Workers that depend on per-thread state should reset it on each ProcessAsync call.
  • First-load latency: the first LoadCodeAsync for a managed worker pays a one-time ~200-500 ms CoreCLR init cost. Plan accordingly for cold-start-sensitive deployments.
  • No multi-runtime: the WM process can have only one CoreCLR instance. If you need to load workers targeting different .NET major versions in the same WM process, this is not yet supported.