Engines
The WorkManager loads a worker nupkg through one of two in-process engines. The engine is selected at CreateWorker time by the mime_type field: application/x-dotnet-dll → DotNetDllEngine; application/x-native-dll → NativeDllEngine. The worker source ships both shims; the WM picks one per worker instance.
The contrast
DotNetDllEngine (managed) |
NativeDllEngine (AOT) |
|
|---|---|---|
| Base class in worker | ApiCommandWorker<T> (extends CommandWorker<T>) |
CommandWorker<T> directly |
| Gateway transport | ManagedGatewayAdapter → Virtufin.Api.Client.GatewayClient.InvokeAsync (managed gRPC) |
AotGatewayAdapter → WorkerContext.InvokeGateway (C-ABI function pointer) |
| Load mechanism | AssemblyLoadContext.LoadFromStream(byte[]) + reflection (IWorkerFinder.FindAndInstantiate(assembly)) |
NativeLibrary.Load(tempFile) + NativeLibrary.GetExport(handle, "Process") |
| Engine runtime in the AOT-published WM | The AOT WM embeds CoreCLR via libhostfxr (the DotNetDll.Bridge is a regular IL DLL loaded into CoreCLR) |
The AOT WM's own native process loads the .so via dlopen/dlsym |
| Worker assembly on disk | lib/net10.0/WebSocketManagerController.dll (IL, machine-independent) |
runtimes/<rid>/native/libWebSocketManagerController.so (native ELF, RID-specific) |
| Class discovery | IWorkerFinder.FindAndInstantiate(assembly) — finds the type implementing IWorker.ProcessAsync(CloudEvent) |
NativeLibrary.GetExport(handle, "Process") + GetExport(handle, "FreeResult") — finds the C-ABI exports |
| Per-worker isolation | Each worker in its own collectible AssemblyLoadContext (VirtufinWorker); DevKit/CloudEvents/Api.Client aliased to the host (default ALC, or bridge component context under AOT WM) |
Each worker in its own NativeLibrary.Load handle; no ALC — single global native symbol space per process |
| Worker unload | ALC.Unload() + GC.Collect() (collectible, but GC-timed) |
NativeLibrary.Free() (deterministic; full symbol teardown) |
| Handler dispatch | Direct in-process: await worker.ProcessAsync(cloudEvent) returns Task<JsonObject?> |
C-ABI: Process(host, inBuf, inLen, outBuf, outLen) writes JSON bytes to outBuf, returns 0 on success / non-zero on error |
| Backend call transport | await gateway.websocketmanager.Connect(dictRequest) (strongly-typed dynamic proxy) |
gateway.InvokeAsync("websocketmanager", "Connect", jsonRequestBytes) (C-ABI host callback) |
| Latency | Sub-microsecond (direct method call) | A few microseconds (one FlatBuffers/JSON round-trip + the C-ABI indirection) |
| Crash isolation | One worker's crash can crash the WM process (no managed sandbox) | Stronger statement: a native segfault terminates the host process. There is no sandbox. Native DLLs must be fully trusted. |
The AOT path is faster for many short calls (no managed dynamic dispatch overhead) but at a significant trust cost. The managed path is easier to develop and supports reflection-based patterns (dependency injection, attribute routing, etc.) but has higher per-call cost for tight loops.
The IGatewayClient abstraction
The two engines differ fundamentally in their ABI surface (managed IWorker.ProcessAsync vs. C-ABI Process(host, inBuf, ...)), but they share the same IGatewayClient contract for outbound calls. The worker author writes:
var response = await gateway.InvokeAsync(
"websocketmanager",
"Connect",
new JsonObject { ["url"] = url, ["auto_reconnect"] = true });
— once. The abstraction routes to:
ManagedGatewayAdapter(in the managedApiCommandWorker<T>base class'sGatewayproperty): wrapsVirtufin.Api.Client.GatewayClient.InvokeAsync(service, method, requestData). TherequestDatais aDictionary<string, object?>; the response is too. The adapter converts to/fromJsonObject.AotGatewayAdapter(constructed in the AOT shim'sGatewayproperty override): wrapsWorkerContext.InvokeGateway(service, method, jsonRequestBytes). ThejsonRequestBytesis abyte[]of JSON; the response is too. The adapter serializes/deserializes to/fromJsonObjectviaSystem.Text.Json(AOT-compatible).
The handler body is identical in both targets — only the base class, the Gateway property source, and the entry-point attribute differ.
How the WM's process looks (AOT build)
When the WM is published with <PublishAot>true</PublishAot>, the resulting binary is a single native ELF (e.g. Virtufin.WorkManager). It does not have a CLR runtime inside it. To run managed workers, it:
- Loads
libhostfxr.so(hostfxr) on first managed-worker load. - Calls
hostfxr_initialize_for_runtime_configto spin up a real CoreCLR instance in the same process. - Loads the DotNetDll.Bridge assembly into that CoreCLR — this is a regular managed DLL that contains the actual
DotNetDllEngineimplementation. - For each managed worker load: extracts the worker DLL from the nupkg's
lib/<tfm>/, callsAssemblyLoadContext.LoadFromStreamon it, finds theIWorkerimplementation, dispatchesProcessAsync. - For each native worker load: extracts the
.sofromruntimes/<rid>/native/, callsNativeLibrary.Load, callsGetExport("Process")andGetExport("FreeResult"), dispatches viaAotNative<T>.ProcessStatic.
This is the design that makes the "one WM binary, two worker types" architecture work.
When to pick which
Default to managed (ApiCommandWorker<T> + DotNetDllEngine):
- The worker is in active development (reflection-based debugging, easy attribute routing, hot-reload via
IWorkerFinder). - The worker needs managed dependencies that aren't AOT-portable (most third-party NuGets).
- The latency budget tolerates a few microseconds per call.
Switch to AOT (CommandWorker<T> directly + NativeDllEngine):
- The worker is performance-critical (tight-loop dispatch, sub-µs budget).
- The worker has no un-AOT-portable dependencies.
- The trust model accepts that the worker is fully trusted (the
.soruns in-process with no sandbox). - The AOT compile time and reproducibility is acceptable (the cross-build image is required; the first compile takes 5-10 min via emulation on arm64 hosts).
Use --format both when you want both options available: a single nupkg with lib/net10.0/*.dll AND runtimes/<rid>/native/lib*.so. Register two workers against the same nupkg URL with different MIME types. The WM picks the engine per worker instance. Useful for staged rollouts (deploy AOT alongside managed, then drain managed workers once AOT is verified).
See also
- Worker Author Guide — the three-file worker pattern in detail
- Packaging Modes — managed / native / both in practice
- Build Script — how
build_aot.pyandbuild_managed.pyproduce the two artifacts