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 collectibleWorkerLoadContextin-process and dispatched with no serialization. - NativeAOT host (the published WorkManager): the AOT runtime has
no JIT, so
AssemblyLoadContext.LoadFromStreamthrowsPlatformNotSupportedException. The engine instead embeds a real CoreCLR vialibhostfxrand loads the bridge assembly (Virtufin.WorkManager.Engine.DotNetDll.Bridge) into it throughhostfxr_get_runtime_delegate(load_assembly_and_get_function_pointer). The bridge (plain IL, running under CoreCLR) does the actualAssemblyLoadContext.LoadFromStream+IWorkerdiscovery 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
CoreCLRinstance. 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
LoadCodeAsyncis 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.AssemblyLoadContextprovides 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\dotneton Windows,/usr/share/dotneton Linux,/usr/local/dotneton macOS). DOTNET_ROOTenvironment variable. Set this to the directory containing the .NET runtime.HostFxrBootstrapresolveslibhostfxrunder$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(notruntime-deps), so the shared framework andlibhostfxrship in the image at/usr/share/dotnet, andDOTNET_ROOTis set to that path.runtime-depshas 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:
- 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.CloudEventsinWorkerBase.BuildResponse,Google.Protobufin gRPC stubs). - The worker ALC's
Load(AssemblyName)is invoked for the same assembly reference. - If the simple name matches an assembly the host already
loaded, the host's instance is returned. This guarantees that
the worker's
CloudEventparameter (worker ALC) and theCommandWorker<T>.HandleCommandAsyncabstract method'sCloudEventparameter (default ALC, via DevKit aliasing) are the sameTypeinstance. 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 aPackageReferencein your worker's.csprojso it ends up in the nuspec's<dependencies>(don't vendor the DLL directly).Method 'HandleAsync' ... does not have an implementation— pre-0.0.60failure mode. Fixed in0.0.60by the canonical- assembly invariant. If you see this on0.0.60+, your worker is referencing aTypefrom 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:
AssemblyLoadContextreload does not preserve[ThreadStatic]state acrossLoadCodeAsynccalls. Workers that depend on per-thread state should reset it on eachProcessAsynccall. - First-load latency: the first
LoadCodeAsyncfor 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
CoreCLRinstance. If you need to load workers targeting different .NET major versions in the same WM process, this is not yet supported.