Table of Contents

DotBoxD API Reference

Core Namespace: DotBoxD.Services

Attributes

DotBoxDServiceAttribute

Marks an interface as a DotBoxD service.

[DotBoxDService]
public interface IMyService { }

// With custom name
[DotBoxDService(Name = "CustomServiceName")]
public interface IMyService { }
Property Type Description
Name string? Custom service name (default: interface name)

DotBoxDMethodAttribute

Optionally customizes an RPC method.

[DotBoxDMethod(Name = "CustomMethodName")]
Task<Result> MyMethodAsync(Request req, CancellationToken ct = default);
Property Type Description
Name string? Custom method name (default: method name)

Caller (single connection)

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 an explicit "this peer does not accept inbound calls" error rather than a "service not found" error. It is not an authentication or authorization boundary.

RpcPeer

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 / member Description
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)
IsConnected Whether the underlying channel is still connected
CloseAsync() / DisposeAsync() Idempotently disposes the peer and underlying channel

RpcPeerSession

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 / member Description
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
Peer The 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

Host (accepting many connections)

RpcHost

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
Member Description
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
PeerConnected Raised after a connection is accepted and configured (RpcPeerEventArgs.Peer)
PeerDisconnected Raised when an accepted peer's read loop ends (RpcPeerEventArgs.Peer)
AcceptError Raised 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.

IServiceDispatcher

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);
}

Peer

RpcPeer

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

Member Description
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 / RemoteEndpoint Channel connection state and remote endpoint string
Disconnected Raised 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
ReadError Raised when the read loop faults
ProtocolError Raised when a malformed or unsupported protocol frame is observed
DispatchError Raised 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

Bidirectional usage

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.

RpcPeerOptions

Options for both RpcPeer and RpcHost.

Property Type Default Description
RequestTimeout TimeSpan 30s Per-call timeout for proxies. Use Timeout.InfiniteTimeSpan to disable
EnableLowAllocationValueTaskInvocations bool false Opts 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
ServiceProvider IServiceProvider? null Resolves dependencies for dispatcher factories and Provide<TService>()
RejectInboundCalls bool false Answers inbound requests with an explicit "does not accept inbound calls" error; makes get-only intent explicit. Not an auth boundary
DisableInboundRequestCancellation bool false Disables per-request cancellation state for non-streaming inbound calls. Handlers receive CancellationToken.None; inbound Cancel frames for those calls are ignored
InboundQueueCapacity int? 1024 Max queued inbound requests (bounded read-side backpressure). null dispatches immediately and does not cap concurrent dispatch work — trusted/bounded transports only
MaxConcurrentInboundDispatch int 1 Max 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
MaxInboundBytes long? 64 MiB Max 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
MaxPendingRequests int 4096 Max concurrent outbound calls awaiting responses
QueueFullMode QueueFullMode Wait Policy 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. It tears down a connection whose in-progress frame read stalls (a slow-loris peer that declares a large frame then trickles or sends nothing), but never times out a connection idly awaiting its next request.

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.


Transport

ITransport

Client-side transport interface.

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

IServerTransport

Server-side transport interface.

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

IRpcChannel

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.

Built-in single-connection primitives

Type Description
StreamConnection IRpcChannel over any duplex Stream, including PipeStream; reads and writes complete DotBoxD length-prefixed frames
SingleConnectionTransport Client ITransport adapter for an already-established IRpcChannel
SingleConnectionServerTransport Server IServerTransport adapter that accepts one already-established IRpcChannel
RpcPeerSession Transport-owned client peer session returned by ConnectPeerAsync

Named Pipe Transport: DotBoxD.Transports.NamedPipes

NamedPipeClientTransport

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)

NamedPipeServerTransport

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

ISerializer

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);
}

Exceptions

Exception Description
ServiceException Base exception for all DotBoxD errors
RemoteServiceException Remote error (includes RemoteExceptionType); non-ServiceException server failures are sanitized
ServiceConnectionException Connection lost or failed
ServiceTimeoutException Request timed out
ServiceNotFoundException Service or method not found

TCP Transport: DotBoxD.Transports.Tcp

TcpTransport

TCP client transport.

public TcpTransport(string host, int port)
Parameter Type Description
host string Server hostname or IP
port int Server port

TcpServerTransport

TCP server transport.

public TcpServerTransport(int port)
public TcpServerTransport(IPAddress address, int port)
public TcpServerTransport(string address, int port)
Parameter Type Description
port int Port to listen on
address IPAddress/string Interface 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

MessagePackRpcSerializer

MessagePack-based serializer.

// Default configuration
var serializer = new MessagePackRpcSerializer();

// Unity-compatible (contractless)
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);
Method Description
CreateUnityCompatible() Creates serializer optimized for Unity/AOT
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.


Generated Extensions

For each [DotBoxDService] 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(IDotBoxDServiceRegistrationSink sink);
    public static void RegisterGeneratedServices(IDotBoxDGeneratedServiceRegistrationSink 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(IDotBoxDServiceRegistrationSink) emits one direct generic call per service, using the generated proxy as TImplementation:

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

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

public interface IDotBoxDGeneratedServiceRegistrationSink
{
    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.


Protocol Format

Wire Format

[4 bytes: Total Length][4 bytes: MessageId][1 byte: MessageType][4 bytes: Envelope Length][E bytes: Envelope][P bytes: Payload]
Field Size Description
Total Length 4 bytes (int32 LE) Full message size including header
Message ID 4 bytes (int32 LE) Request/response correlation ID
Message Type 1 byte 0x01=Request, 0x02=Response, 0x03=Error, 0x04=Cancel
Envelope Length 4 bytes (int32 LE) Size of the serialized envelope
Envelope Variable Serialized RpcRequest/RpcResponse metadata
Payload Variable Raw serialized arguments/return value
Note

All multi-byte integers are in LE (Little Endian) format.

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.

Message Types

Value Type Description
0x01 Request RPC request from client
0x02 Response Successful response from server
0x03 Error Error response from server
0x04 Cancel Envelope-less cancellation frame for an in-flight request id

Request Envelope

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.

Response 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.