Skip to content

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-dllDotNetDllEngine; application/x-native-dllNativeDllEngine. 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 ManagedGatewayAdapterVirtufin.Api.Client.GatewayClient.InvokeAsync (managed gRPC) AotGatewayAdapterWorkerContext.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 managed ApiCommandWorker<T> base class's Gateway property): wraps Virtufin.Api.Client.GatewayClient.InvokeAsync(service, method, requestData). The requestData is a Dictionary<string, object?>; the response is too. The adapter converts to/from JsonObject.
  • AotGatewayAdapter (constructed in the AOT shim's Gateway property override): wraps WorkerContext.InvokeGateway(service, method, jsonRequestBytes). The jsonRequestBytes is a byte[] of JSON; the response is too. The adapter serializes/deserializes to/from JsonObject via System.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:

  1. Loads libhostfxr.so (hostfxr) on first managed-worker load.
  2. Calls hostfxr_initialize_for_runtime_config to spin up a real CoreCLR instance in the same process.
  3. Loads the DotNetDll.Bridge assembly into that CoreCLR — this is a regular managed DLL that contains the actual DotNetDllEngine implementation.
  4. For each managed worker load: extracts the worker DLL from the nupkg's lib/<tfm>/, calls AssemblyLoadContext.LoadFromStream on it, finds the IWorker implementation, dispatches ProcessAsync.
  5. For each native worker load: extracts the .so from runtimes/<rid>/native/, calls NativeLibrary.Load, calls GetExport("Process") and GetExport("FreeResult"), dispatches via AotNative<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 .so runs 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