Skip to content

DotBoxD API Reference

Marks an interface as a DotBoxD service.

[RpcService]
public interface IMyService { }
// With custom name
[RpcService(Name = "CustomServiceName")]
public interface IMyService { }
PropertyTypeDescription
Namestring?Custom service name (default: interface name)

Optionally customizes an RPC method.

[RpcMethod(Name = "CustomMethodName")]
Task<Result> MyMethodAsync(Request req, CancellationToken ct = default);
PropertyTypeDescription
Namestring?Custom method name (default: method name)

A caller is an RpcPeerSession over a connected transport, or an RpcPeer over an already-connected channel. Use the session helper when the peer should own and dispose the transport.

using DotBoxD.Services;
using DotBoxD.Services.Generated; // generated Provide.../Get... extensions
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.Tcp;
var transport = new TcpTransport("127.0.0.1", 5000);
await using var session = await transport.ConnectPeerAsync(
new MessagePackRpcSerializer(),
new RpcPeerOptions { RejectInboundCalls = true }); // get-only intent
var svc = session.Peer.GetMyService();
var result = await svc.DoAsync(/* ... */);

Setting RpcPeerOptions.RejectInboundCalls = true makes the caller’s get-only intent explicit: the other side receives a clear “this peer does not accept inbound calls” error rather than a “service not found” error. It is not an authentication or authorization boundary.

Symmetric endpoint over one duplex IRpcChannel. It can provide local services and create generated proxies for services provided by the remote side. See the Peer section below for the full member list.

Factory / memberDescription
RpcPeer.Over(IRpcChannel, ISerializer, RpcPeerOptions?)Creates a peer over the channel
Provide<TService>(TService) / Provide(IServiceDispatcher)Registers an inbound service (before Start)
Get<TService>()Creates a generated proxy for a remote service
Start()Begins the read loop (idempotent; invoking a method also starts it)
IsConnectedWhether the underlying channel is still connected
CloseAsync() / DisposeAsync()Idempotently disposes the peer and underlying channel

Owns a connected ITransport and the RpcPeer running over it. This is the preferred caller shape when integrating host-side IPC packages because it avoids transport-specific wrapper types.

Factory / memberDescription
transport.ConnectPeerAsync(ISerializer, RpcPeerOptions?, CancellationToken)Connects a client transport, creates and starts a peer, and returns an owning session
transport.ConnectPeerAsync(ISerializer, Action<RpcPeer>, RpcPeerOptions?, CancellationToken)Same as above, but configures local provided services before the read loop starts
RpcPeerSession.ConnectAsync(...)Static equivalent for callers that prefer factory syntax
PeerThe connected peer; use generated Get... / Provide... extension methods here
Get<TService>()Convenience proxy factory forwarding to Peer.Get<TService>()
DisposeAsync()Disposes the peer first, then the transport

Accepts connections from a listener and turns each one into an RpcPeer. Because each accepted connection is a full peer, the host can both provide services to and call back into the peers that connect to it.

using DotBoxD.Services;
using DotBoxD.Services.Generated;
using DotBoxD.Codecs.MessagePack;
using DotBoxD.Transports.Tcp;
await using var host = RpcHost
.Listen(new TcpServerTransport(5000), new MessagePackRpcSerializer())
.ForEachPeer(peer => peer.ProvideMyService(new MyService()));
host.PeerConnected += (_, args) => Console.WriteLine($"connected: {args.Peer.RemoteEndpoint}");
await host.StartAsync();
// ...
await host.StopAsync(); // DisposeAsync also stops the host and closes every accepted peer
MemberDescription
RpcHost.Listen(IServerTransport, ISerializer, RpcPeerOptions?)Creates a host bound to a listener
ForEachPeer(Action<RpcPeer>)Configures every accepted peer before its read loop starts (call Provide.../Get... here); can be chained
StartAsync(CancellationToken)Starts the accept loop
StopAsync(CancellationToken)Stops accepting, closes the listener, and closes every accepted peer
DisposeAsync()Stops the host (if running) and disposes the listener
PeerConnectedRaised after a connection is accepted and configured (RpcPeerEventArgs.Peer)
PeerDisconnectedRaised when an accepted peer’s read loop ends (RpcPeerEventArgs.Peer)
AcceptErrorRaised when the accept loop catches a non-cancellation exception (RpcHostErrorEventArgs)

Services provided through ForEachPeer are callable by any accepted peer. DotBoxD does not add authentication or authorization; enforce access control at the transport or application layer.

Interface for service dispatchers (generated).

public interface IServiceDispatcher
{
string ServiceName { get; }
Task DispatchAsync(
string method,
ReadOnlyMemory<byte> payload,
ISerializer serializer,
IInstanceRegistry registry,
IBufferWriter<byte> output,
CancellationToken ct = default);
}

Symmetric endpoint over one duplex IRpcChannel. It can provide local services and create generated proxies for services provided by the remote side.

MemberDescription
RpcPeer.Over(IRpcChannel, ISerializer, RpcPeerOptions?)Creates a peer over the channel; call Start() (or invoke a method) to begin the read loop
Provide<TService>(TService) / Provide(IServiceDispatcher)Registers an inbound service (must be called before the peer starts)
Get<TService>()Creates a generated proxy for a remote service
Start()Begins the read loop; idempotent and chainable
IsConnected / RemoteEndpointChannel connection state and remote endpoint string
DisconnectedRaised when a remote close or read error ends the read loop; local close/dispose does not raise it. Handlers run on the teardown path and should not block
ReadErrorRaised when the read loop faults
ProtocolErrorRaised when a malformed or unsupported protocol frame is observed
DispatchErrorRaised when inbound request dispatch or response sending fails after a request was accepted
CloseAsync() / DisposeAsync()Idempotently disposes the peer and underlying connection; closed peers cannot be restarted

Both sides of a connection are RpcPeer instances over one duplex IRpcChannel. Each side may Provide services and Get proxies, so calls flow in both directions over the same connection.

// Generated Provide.../Get... extension method names drop the leading "I" of the interface
// (IChatRoom -> ProvideChatRoom / GetChatRoom).
// Side A provides a chat room and can call back into B.
await using var a = RpcPeer
.Over(channelA, serializer)
.ProvideChatRoom(new ChatRoom())
.Start();
var participant = a.GetChatParticipant(); // A calls the connecting peer
// Side B provides the participant callback and calls the room.
await using var b = RpcPeer
.Over(channelB, serializer)
.ProvideChatParticipant(new ChatParticipant())
.Start();
var room = b.GetChatRoom(); // B calls A

On a host, the per-connection peer is configured in RpcHost.ForEachPeer; obtain the peer from PeerConnected (args.Peer) to call back into a connecting peer over the same connection.

Cancelling an in-flight outbound call sends a DotBoxD cancel frame for that request. The receiving peer continues reading the connection while dispatch runs and cancels the matching dispatcher token when that frame arrives.

Options for both RpcPeer and RpcHost.

PropertyTypeDefaultDescription
RequestTimeoutTimeSpan30sPer-call timeout for proxies. Use Timeout.InfiniteTimeSpan to disable
EnableLowAllocationValueTaskInvocationsboolfalseOpts generated generic ValueTask<T> unary proxy calls into the pooled response-source path. This alone does not guarantee that path: the call must use the non-timeout/non-cancellable call shape, and the transport/runtime must support the low-allocation path; otherwise the proxy uses the Task<T>-backed path
ServiceProviderIServiceProvider?nullResolves dependencies for dispatcher factories and Provide<TService>()
RejectInboundCallsboolfalseAnswers inbound requests with an explicit “does not accept inbound calls” error; makes get-only intent explicit. Not an auth boundary
DisableInboundRequestCancellationboolfalseDisables per-request cancellation state for non-streaming inbound calls. Handlers receive CancellationToken.None; inbound Cancel frames for those calls are ignored
InboundQueueCapacityint?1024Max queued inbound requests (bounded read-side backpressure). null dispatches immediately and does not cap concurrent dispatch work - trusted peers or externally bounded transports only
MaxConcurrentInboundDispatchint1Max inbound requests dispatched concurrently when InboundQueueCapacity is set. Default 1 dispatches serially per connection; raise it for bounded-concurrent per-connection dispatch. Ignored when InboundQueueCapacity is null
MaxInboundByteslong?64 MiBMax total bytes of in-flight inbound request frames when InboundQueueCapacity is set. Caps peak memory independent of frame count; null disables. An oversized frame is still admitted when nothing else is in flight, so one large request never deadlocks. Ignored when InboundQueueCapacity is null
MaxPendingRequestsint4096Max concurrent outbound calls awaiting responses
QueueFullModeQueueFullModeWaitPolicy when InboundQueueCapacity is set and the request queue is full (Wait applies backpressure; DropIncoming rejects)

The TCP transport additionally enforces a per-frame read idle timeout (TcpConnection, default 30s; Timeout.InfiniteTimeSpan disables), set via TcpServerTransport.FrameReadIdleTimeout / TcpTransport.FrameReadIdleTimeout. StreamConnection and MessageFramer.ReadMessageAsync use the same finite 30s default unless callers pass an explicit timeout. The timeout covers a receive with no new frame bytes, including the first byte of the next frame, so long-lived idle links should use an application heartbeat, a larger timeout, or Timeout.InfiniteTimeSpan for trusted transports.

RejectInboundCalls is not an authentication or authorization boundary. Any connected peer can still send request frames; secure transports or application-level checks should enforce trust.

Setting InboundQueueCapacity to null dispatches inbound peer requests immediately and does not cap concurrent dispatcher work; use that only with trusted peers or externally bounded transports. In Wait mode, queued requests are bounded and read-side backpressure applies instead of retaining unbounded request frames.

The default profile is intentionally safe: outbound calls have timeouts, inbound handlers receive cancellable tokens, inbound dispatch is bounded, and generated ValueTask<T> proxies use the Task<T>-backed path. For measured hot paths that can trade those guarantees for lower allocation, see Performance Hot Paths.


Client-side transport interface.

public interface ITransport : IAsyncDisposable
{
Task ConnectAsync(CancellationToken ct = default);
IRpcChannel? Connection { get; }
bool IsConnected { get; }
}

Server-side transport interface.

public interface IServerTransport : IAsyncDisposable
{
Task StartAsync(CancellationToken ct = default);
Task<IRpcChannel> AcceptAsync(CancellationToken ct = default);
Task StopAsync(CancellationToken ct = default);
}

The duplex, framed channel an RpcPeer runs on. Responses flow back over the same channel, so it is always bidirectional even when the call direction is one-way.

public interface IRpcChannel : IAsyncDisposable
{
Task SendAsync(ReadOnlyMemory<byte> data, CancellationToken ct = default);
Task<Payload> ReceiveAsync(CancellationToken ct = default);
bool IsConnected { get; }
string RemoteEndpoint { get; }
}

A Payload with Length of 0 returned from ReceiveAsync signals the channel was closed. The caller owns the returned Payload and must dispose it. Implement IRpcChannel to add a custom transport.

TypeDescription
StreamConnectionIRpcChannel over any duplex Stream, including PipeStream; reads and writes complete DotBoxD length-prefixed frames
SingleConnectionTransportClient ITransport adapter for an already-established IRpcChannel
SingleConnectionServerTransportServer IServerTransport adapter that accepts one already-established IRpcChannel
RpcPeerSessionTransport-owned client peer session returned by ConnectPeerAsync

Named Pipe Transport: DotBoxD.Transports.NamedPipes

Section titled “Named Pipe Transport: DotBoxD.Transports.NamedPipes”

Named-pipe client transport for process-boundary IPC.

public NamedPipeClientTransport(string pipeName, int maxMessageSize = MessageFramer.MaxMessageSize)
public NamedPipeClientTransport(string serverName, string pipeName, int maxMessageSize = MessageFramer.MaxMessageSize)

Named-pipe server transport.

public NamedPipeServerTransport(
string pipeName,
int maxAllowedServerInstances = NamedPipeServerStream.MaxAllowedServerInstances,
int maxMessageSize = MessageFramer.MaxMessageSize)

Both transports wrap NamedPipeClientStream/NamedPipeServerStream in the core StreamConnection, so they use the same DotBoxD frame validation, send serialization, and clean EOF behavior as any other stream-backed connection.

// Host side
await using var host = RpcHost
.Listen(new NamedPipeServerTransport("my-plugin-pipe"), new MessagePackRpcSerializer())
.ForEachPeer(peer => peer.ProvideMyService(new MyService()));
await host.StartAsync();
// Caller side
var transport = new NamedPipeClientTransport("my-plugin-pipe");
await using var session = await transport.ConnectPeerAsync(new MessagePackRpcSerializer());
var svc = session.Peer.GetMyService();

Serialization interface.

public interface ISerializer
{
void Serialize<T>(IBufferWriter<byte> writer, T value);
T Deserialize<T>(ReadOnlyMemory<byte> data);
object? Deserialize(ReadOnlyMemory<byte> data, Type type);
}

ExceptionDescription
ServiceExceptionBase exception for all DotBoxD errors
RemoteServiceExceptionRemote error (includes RemoteExceptionType); non-ServiceException server failures are sanitized
ServiceConnectionExceptionConnection lost or failed
ServiceTimeoutExceptionRequest timed out
ServiceNotFoundExceptionService or method not found

TCP client transport.

public TcpTransport(string host, int port)
ParameterTypeDescription
hoststringServer hostname or IP
portintServer port

TCP server transport.

public TcpServerTransport(int port)
public TcpServerTransport(IPAddress address, int port)
public TcpServerTransport(string address, int port)
ParameterTypeDescription
portintPort to listen on
addressIPAddress/stringInterface to bind (default: IPAddress.Any)

TcpServerTransport.LocalEndpoint exposes the bound endpoint after StartAsync succeeds, including the OS-assigned port when the transport is created with port 0.


MessagePack Serializer: DotBoxD.Codecs.MessagePack

Section titled “MessagePack Serializer: DotBoxD.Codecs.MessagePack”

MessagePack-based serializer.

// Default configuration
var serializer = new MessagePackRpcSerializer();
// Legacy Unity contractless preset (reflection-capable; not an AOT guarantee)
var serializer = MessagePackRpcSerializer.CreateUnityCompatible();
// Custom options
var serializer = new MessagePackRpcSerializer(customOptions);
// Custom resolver with DotBoxD binary payload formatters and standard fallbacks
var serializer = MessagePackRpcSerializer.CreateWithResolver(myResolver);
MethodDescription
CreateUnityCompatible()Legacy contractless preset; reflection-capable and not an AOT guarantee
CreateWithResolver(IFormatterResolver)Creates serializer with a custom resolver chain
CreateOptions(params IFormatterResolver[])Builds hardened MessagePack options with DotBoxD formatters

The default options include a formatter for ReadOnlyMemory<byte> so binary DTO fields encode as MessagePack bin payloads.


For each [RpcService] interface IFooService, the generator creates RpcPeer extension methods. The method suffix drops the leading I of the interface name (IFooService -> ProvideFooService / GetFooService):

// In namespace DotBoxD.Services.Generated
public static class DotBoxDGeneratedExtensions
{
// Provide a local implementation for the other peer to call (before the peer starts).
public static RpcPeer ProvideFooService(this RpcPeer peer, IFooService implementation);
// Get a proxy to call IFooService on the other peer.
public static IFooService GetFooService(this RpcPeer peer);
}

The generator also emits a public factory class and registers factories with the runtime registry. The proxy factories take an IRpcInvoker (an RpcPeer implements it), so pass the peer directly:

// In namespace DotBoxD.Services.Generated
public static class DotBoxDGenerated
{
public static IReadOnlyList<GeneratedService> Services { get; }
public static void RegisterServices(IRpcServiceRegistrationSink sink);
public static void RegisterGeneratedServices(IRpcGeneratedServiceRegistrationSink sink);
public static TService CreateProxy<TService>(IRpcInvoker invoker) where TService : class;
public static object CreateProxy(Type serviceInterface, IRpcInvoker invoker);
public static IServiceDispatcher CreateDispatcher<TService>(TService implementation) where TService : class;
public static IServiceDispatcher CreateDispatcher(Type serviceInterface, object implementation);
}

CreateDispatcher<TService>(impl) produces an IServiceDispatcher you register with peer.Provide(dispatcher); CreateProxy<TService>(invoker) produces a proxy bound to the peer (equivalent to peer.Get<TService>() and the generated Get... extension).

DotBoxDGenerated.Services is backed by a generated static array of GeneratedService records. Each descriptor includes ServiceType, ProxyType, DispatcherType, and ServiceName, so hosts can build a service map without scanning assembly types.

RegisterServices(IRpcServiceRegistrationSink) emits one direct generic call per service, using the generated proxy as TImplementation:

public interface IRpcServiceRegistrationSink
{
void AddService<TService, TImplementation>()
where TService : class
where TImplementation : TService;
}

RegisterGeneratedServices(IRpcGeneratedServiceRegistrationSink) emits one direct generic call per service with service, proxy, and dispatcher types:

public interface IRpcGeneratedServiceRegistrationSink
{
void AddService<TService, TProxy, TDispatcher>()
where TService : class
where TProxy : TService
where TDispatcher : IServiceDispatcher;
}

The runtime registry is available as DotBoxD.Services.Generated.GeneratedServiceRegistry and throws a clear diagnostic when no generated factory is registered for a service interface. It also exposes GetService(Type), GetServices(Assembly), GetServices(IEnumerable<Assembly>), and multi-assembly sink registration helpers for dynamic hosts that need generated metadata. See Generated Service Registry for examples and assembly-scope details.


[4 bytes: Total Length][4 bytes: MessageId][1 byte: MessageType][4 bytes: Envelope Length][E bytes: Envelope][P bytes: Payload]
FieldSizeDescription
Total Length4 bytes (int32 LE)Full message size including header
Message ID4 bytes (int32 LE)Request/response correlation ID
Message Type1 byte0x01=Request, 0x02=Response, 0x03=Error, 0x04=Cancel
Envelope Length4 bytes (int32 LE)Size of the serialized envelope
EnvelopeVariableSerialized RpcRequest/RpcResponse metadata
PayloadVariableRaw serialized arguments/return value

The payload is not nested inside the envelope. It is appended as raw trailing bytes so the receiver can hand it to the dispatcher (or deserialize the return value) as a zero-copy slice of the frame buffer, avoiding a per-message heap allocation. The envelope-length prefix lets the receiver locate the payload without the serializer reporting how many bytes it consumed.

Cancel frames use only the 9-byte frame header and the message id of the request being cancelled; they do not include an RPC envelope.

ValueTypeDescription
0x01RequestRPC request from client
0x02ResponseSuccessful response from server
0x03ErrorError response from server
0x04CancelEnvelope-less cancellation frame for an in-flight request id
public class RpcRequest
{
public int MessageId { get; set; }
public string ServiceName { get; set; }
public string MethodName { get; set; }
public string? InstanceId { get; set; } // Target sub-service instance, null for singletons
}

The serialized method arguments travel as the frame’s trailing payload, not inside this envelope.

public class RpcResponse
{
public int MessageId { get; set; }
public bool IsSuccess { get; set; }
public string? ErrorMessage { get; set; }
public string? ErrorType { get; set; }
}

The serialized return value travels as the frame’s trailing payload, not inside this envelope.