In previous posts, we've used WitRPC to call remote methods and handle server events. It felt like magic: you call a method on an interface, and somehow it executes on a different machine. Today, we pull back the curtain and explore how WitRPC actually works.
Understanding the internals isn't just academic curiosity. It helps you debug issues, make informed architecture decisions, and appreciate why WitRPC behaves the way it does.
The Big Picture
When you write this code:
var service = client.GetService<ITaskService>();
bool result = service.StartTask("MyTask");What actually happens? Let's trace the journey:
The response travels the same path in reverse. Let's examine each component.
Dynamic Proxy Generation
The heart of WitRPC's magic is the dynamic proxy. When you call client.GetService<ITaskService>(), WitRPC creates a class at runtime that implements ITaskService.
How Castle DynamicProxy Works
WitRPC uses Castle DynamicProxy, a mature .NET library for runtime proxy generation. Here's the conceptual flow:
// What you write
ITaskService service = client.GetService<ITaskService>();
// What happens internally (simplified)
public T GetService<T>() where T : class
{
var proxyGenerator = new ProxyGenerator();
var interceptor = new WitRpcInterceptor(this, typeof(T));
// Castle creates a class at runtime that implements T
// Every method call is routed through the interceptor
return proxyGenerator.CreateInterfaceProxyWithoutTarget<T>(interceptor);
}Castle DynamicProxy uses System.Reflection.Emit to generate IL code at runtime. The generated class looks conceptually like this:
// Pseudo-code of what Castle generates
public class TaskServiceProxy : ITaskService
{
private readonly IInterceptor _interceptor;
public bool StartTask(string taskName)
{
var invocation = new Invocation(
method: typeof(ITaskService).GetMethod("StartTask"),
arguments: new object[] { taskName }
);
_interceptor.Intercept(invocation);
return (bool)invocation.ReturnValue;
}
// ... other interface methods similarly wrapped
}Every method call becomes an Invocation object passed to the interceptor. The interceptor decides what to do; in WitRPC's case, it sends the call over the network.
The Interceptor: Where RPC Happens
The interceptor is where local calls become remote calls:
// Simplified WitRPC interceptor logic
public class WitRpcInterceptor : IInterceptor
{
private readonly WitClient _client;
private readonly Type _serviceType;
public void Intercept(IInvocation invocation)
{
// 1. Build request message
var request = new RpcRequest
{
ServiceType = _serviceType.FullName,
MethodName = invocation.Method.Name,
Arguments = invocation.Arguments,
ArgumentTypes = invocation.Method.GetParameters()
.Select(p => p.ParameterType).ToArray()
};
// 2. Serialize and send
var response = _client.SendRequest(request);
// 3. Handle response
if (response.Exception != null)
{
throw new WitExceptionFault(response.Exception);
}
invocation.ReturnValue = response.Result;
}
}For async methods, the interceptor returns a Task that completes when the server responds:
if (invocation.Method.ReturnType == typeof(Task) ||
invocation.Method.ReturnType.IsGenericType &&
invocation.Method.ReturnType.GetGenericTypeDefinition() == typeof(Task<>))
{
// Return a Task that completes when server responds
invocation.ReturnValue = SendRequestAsync(request);
}Convention-Based Method Matching
WitRPC uses a convention-based approach to match client calls to server methods. When a request arrives at the server:
- Find the service implementation registered for the requested interface type
- Find the method with the matching name and parameter types
- Deserialize arguments to the expected types
- Invoke the method via reflection
- Serialize and return the result
// Server-side request handling (simplified)
public object HandleRequest(RpcRequest request)
{
// Find registered service
var service = _services[request.ServiceType];
// Find method by name and signature
var method = service.GetType().GetMethod(
request.MethodName,
request.ArgumentTypes
);
// Invoke
var result = method.Invoke(service, request.Arguments);
// Handle async methods
if (result is Task task)
{
await task;
if (task.GetType().IsGenericType)
{
result = ((dynamic)task).Result;
}
}
return result;
}This convention-based approach is why you don't need [OperationContract] attributes: the method name and signature are the contract.
Event Handling: Server-to-Client Push
Events are where WitRPC really shines. Let's trace how a server event reaches client handlers.
Server Side: Event Interception
When you register a service with WitRPC, the framework wraps your implementation to intercept event invocations:
// Your service implementation
public class TaskService : ITaskService
{
public event Action<double> ProgressChanged = delegate { };
private void DoWork()
{
ProgressChanged(0.5); // This needs to reach clients
}
}WitRPC replaces your event's backing delegate with one that broadcasts to all connected clients:
// Conceptually what WitRPC does
public void WrapServiceEvents(object service, Type interfaceType)
{
foreach (var eventInfo in interfaceType.GetEvents())
{
// Create a handler that broadcasts to clients
var broadcastHandler = CreateBroadcastHandler(eventInfo);
// Subscribe to the event
eventInfo.AddEventHandler(service, broadcastHandler);
}
}
private Delegate CreateBroadcastHandler(EventInfo eventInfo)
{
return (args) =>
{
var eventMessage = new EventNotification
{
EventName = eventInfo.Name,
Arguments = args
};
// Send to all subscribed clients
foreach (var client in _subscribedClients[eventInfo.Name])
{
client.SendEvent(eventMessage);
}
};
}Client Side: Event Subscription
On the client, the proxy also intercepts event subscriptions:
// When you write this:
service.ProgressChanged += progress => Console.WriteLine(progress);
// The proxy intercepts the += operatorCastle DynamicProxy handles event subscription specially. The proxy maintains local handlers and notifies the server about subscriptions:
// Proxy event handling (conceptual)
public class TaskServiceProxy : ITaskService
{
private Action<double> _progressChangedHandlers;
public event Action<double> ProgressChanged
{
add
{
_progressChangedHandlers += value;
// First subscriber? Tell server we're interested
if (_progressChangedHandlers.GetInvocationList().Length == 1)
{
_client.Subscribe("ProgressChanged");
}
}
remove
{
_progressChangedHandlers -= value;
// No more subscribers? Unsubscribe from server
if (_progressChangedHandlers == null)
{
_client.Unsubscribe("ProgressChanged");
}
}
}
// Called when server sends an event
internal void RaiseProgressChanged(double value)
{
_progressChangedHandlers?.Invoke(value);
}
}The Event Flow
Putting it together:
Thread Considerations
Events arrive on a background thread managed by WitRPC's transport layer. This has implications:
// Events fire on background threads!
service.ProgressChanged += progress =>
{
// This runs on a WitRPC thread, not the UI thread
// For WPF:
Dispatcher.Invoke(() => progressBar.Value = progress);
// For WinForms:
BeginInvoke(() => progressBar.Value = (int)(progress * 100));
// For Blazor:
InvokeAsync(() => { this.progress = progress; StateHasChanged(); });
};WitRPC doesn't automatically marshal to a synchronization context because the "right" context depends on your application type.
Serialization Pipeline
Every message (requests, responses, events) passes through the serialization layer.
Message Structure
WitRPC uses a two-layer serialization approach:
The envelope uses a fixed binary format for efficiency. The payload uses your chosen serializer (JSON, MessagePack, etc.).
Serializer Choice Impact
Different serializers have different characteristics:
| Serializer | Size | Speed | Human-Readable | Complex Types |
|---|---|---|---|---|
| JSON | Large | Slow | ✅ Yes | Good |
| MessagePack | Small | Fast | ❌ No | Good |
| MemoryPack | Smallest | Fastest | ❌ No | Requires attributes |
| ProtoBuf | Small | Fast | ❌ No | Requires attributes |
For debugging, JSON is invaluable: you can inspect wire traffic with tools like Wireshark or Fiddler. For production with high throughput, binary serializers provide significant gains.
Type Handling
WitRPC needs to serialize types that both client and server understand. By default, it uses strong type matching:
// Both client and server must have the same type
public class OrderInfo
{
public int Id { get; set; }
public string Customer { get; set; }
}
// If OrderInfo is in a shared assembly, it works out of the box
// If it's in different assemblies with same namespace/name, WitRPC can match by nameFor complex scenarios, you can configure type resolution:
options.WithTypeResolver(new CustomTypeResolver());Transport Abstraction
WitRPC's transport layer is pluggable. Each transport implements the same interface:
public interface ITransport : IDisposable
{
event TransportDataEventHandler Callback; // (Guid sender, byte[] data)
event TransportEventHandler Disconnected; // (Guid sender)
Task SendBytesAsync(byte[] data);
Guid Id { get; }
}This is the actual interface from the source. Note how small it is: a transport only knows how to move bytes and report who they came from. Connection setup lives in transport-specific factories, and everything above this line (serialization, encryption, request correlation) is transport-agnostic by construction.
Transport Comparison
| Transport | Use Case | Relative speed | Setup |
|---|---|---|---|
| Memory-Mapped File | Same machine, max speed | Fastest measured | Simple |
| Named Pipes | Same machine, easy | Close second locally | Simple |
| TCP | Network, performance | Network-bound | Port config |
| WebSocket | Web clients, firewalls | Network-bound | URL config |
| REST | Interop, stateless | Network-bound, per-request | URL config |
The transport choice doesn't affect your service code, only the configuration:
// Same service, different transports: one line changes
options.WithService(taskService);
options.WithMemoryMappedFile("MyApp"); // Option A: local IPC
options.WithTcp(5000, maxNumberOfClients: 100); // Option B: network
options.WithWebSocket("http://0.0.0.0:8080", maxClients: 100); // Option C: webException Propagation
When server methods throw, clients need to know. WitRPC serializes exception information:
// Server
public bool StartTask(string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name required", nameof(name));
// ...
}
// Client
try
{
service.StartTask("");
}
catch (WitExceptionFault ex)
{
// ex.Message carries the server-side error information
Console.WriteLine($"Server error: {ex.Message}");
}The exception hierarchy:
WitException (base)
├── WitExceptionFault // the server implementation threw
├── WitExceptionTransport // transport-level failure (connection lost, unreachable)
├── WitExceptionSerialization // payload could not be (de)serialized
└── WitExceptionEncryption // encryption handshake or crypto operation failedCatching WitExceptionFault separates business failures from communication problems; catching the base WitException covers the rest.
Static Proxies for AOT
Dynamic proxy generation requires JIT compilation. In AOT environments (iOS, Blazor WASM with AOT, Native AOT), this isn't available.
WitRPC supports static proxies, pre-compiled proxy classes:
// Hand-written or generated static proxy
public class TaskServiceStaticProxy : RequestInterceptor, ITaskService
{
public TaskServiceStaticProxy(WitClient client) : base(client) { }
public bool StartTask(string taskName)
{
return Invoke<bool>("StartTask", taskName);
}
public event Action<double> ProgressChanged
{
add => Subscribe("ProgressChanged", value);
remove => Unsubscribe("ProgressChanged", value);
}
// ... other members
}
// Usage in AOT environment
var service = client.GetService<ITaskService>(
() => new TaskServiceStaticProxy(client)
);WitRPC can generate these proxies at build time using a source generator, making AOT support transparent.
Connection Lifecycle
Understanding the connection lifecycle helps with debugging:
You can observe connection state:
client.Disconnected += sender => Console.WriteLine("Disconnected");
// Reconnection is observed through the auto-reconnect callbacks:
options.WithAutoReconnect(r =>
{
r.OnReconnecting = (attempt, delay) => Console.WriteLine($"Reconnecting, attempt {attempt}...");
r.OnReconnected = () => Console.WriteLine("Reconnected");
});
// And queried at any time:
var state = client.ConnectionState; // Connected / Reconnecting / Disconnected / FailedDebugging Tips
Understanding the internals helps with debugging:
1. Enable Logging
options.WithLogger(new ConsoleLogger(LogLevel.Debug));This shows message flow, timing, and errors.
2. Use JSON for Development
// Development: readable wire format
options.WithJson();
// Production: performance
options.WithMemoryPack();3. Inspect with Network Tools
For TCP/WebSocket transports, use Wireshark or similar tools to inspect traffic. With JSON serialization, messages are human-readable.
4. Check Event Subscriptions
If events aren't firing, verify:
- Client subscribed before server raised event
- No exceptions in event handlers (they can silently fail)
- Connection is still active
5. Handle Disconnections
client.Disconnected += () =>
{
// Service proxy is no longer usable
// Reconnect or notify user
};Conclusion
WitRPC's internals are built on proven patterns:
- Dynamic Proxies (Castle DynamicProxy) make remote calls feel local
- Convention-based routing eliminates configuration ceremony
- Event interception enables natural server-to-client push
- Pluggable serialization balances debuggability and performance
- Pluggable transports adapt to different deployment scenarios
- Static proxy support ensures AOT compatibility
Understanding these mechanisms helps you:
- Debug issues more effectively
- Choose appropriate transports and serializers
- Design services that work with (not against) the framework
- Appreciate why certain patterns are recommended
The goal of all this machinery is simple: let you write distributed code that looks and feels like local code. The complexity is hidden so you can focus on your application logic.
Next up: Advanced WitRPC Usage: Composite Services for Multi-Interface Servers, hosting multiple services on a single connection.
This is part 6 of a series on WitRPC. See previous posts: Introducing WitRPC, Getting Started, Real-World Benefits, WitRPC vs. gRPC, and Migrating to WitRPC.