Skip to content

ActualLab.Rpc Configuration Options

This document describes the configuration options available in ActualLab.Rpc.

Overview

ActualLab.Rpc provides several options classes for fine-tuning RPC behavior:

Options ClassPurpose
RpcLimitsConnection timeouts, keep-alive, object lifecycle
RpcPeerOptionsPeer creation and connection lifecycle
RpcOutboundCallOptionsOutbound call routing, timeouts, rerouting
RpcInboundCallOptionsInbound call processing
RpcDiagnosticsOptionsCall tracing and logging
RpcRegistryOptionsService and method registration
RpcWebSocketClientOptionsWebSocket client connections
RpcWebSocketServerOptionsWebSocket server endpoints
RpcTestClientOptionsTesting with in-memory channels

RpcLimits

Defines timeout and periodic limits for RPC connections, keep-alive, and object lifecycle. Registered as a singleton in DI and accessible via RpcHub.Limits.

Properties

PropertyTypeDefaultDescription
ConnectTimeoutTimeSpan10sTimeout for establishing a connection; reconnect starts if exceeded
HandshakeTimeoutTimeSpan10sTimeout for completing handshake; reconnect starts if exceeded
PrematureDisconnectTimeoutTimeSpan15sIf a connection was alive for less than this duration, a graceful close is still treated as an error — bumping ConnectionAttemptIndex and applying reconnect backoff delay. This prevents rapid connect-disconnect cycles from resetting the backoff.
KeepAlivePeriodTimeSpan10sInterval at which a peer sends keep-alive messages (which also report which remote objects are still alive)
KeepAliveTimeoutTimeSpan25sIf no keep-alive is received within this period, the connection is dropped and reconnect starts. Sized to tolerate a ~15s server stall plus most of one keepalive cycle — the worst-case age of LastKeepAliveAt is KeepAlivePeriod + stall_duration.
ObjectReleasePeriodTimeSpan10sCycle time for checking KeepAliveTimeout and ObjectReleaseTimeout
ObjectReleaseTimeoutTimeSpan125sIf an object doesn't receive a keep-alive for this long, it gets released
ObjectAbortCycleCountint3Number of cycles to complete object abort (proceeds to next cycle if at least one object was disposed)
ObjectAbortCyclePeriodTimeSpan1sDuration of a single object abort cycle
CallCountLimitintint.MaxValueBackstop cap on InboundCalls.Count + OutboundCalls.Count per peer; the peer is reset when it's exceeded
ObjectCountLimitint65536Backstop cap on SharedObjects.Count + RemoteObjects.Count per peer; the peer is reset when it's exceeded
CallTimeoutCheckPeriodRandomTimeSpan5s ±20%How often call timeouts are checked
DisconnectCheckPeriodRandomTimeSpan1s ±20%Safety-poll period of the per-outage disconnect watcher (RpcOutboundCallTracker.HandleDisconnect), which runs only while the peer is disconnected. Registering a call, a deadline it computed, and a rescheduled reconnect each wake it directly, so this tick is a backstop

When a debugger is attached, the defaults for HandshakeTimeout (60s), KeepAlivePeriod (300s), and KeepAliveTimeout (1000s) are relaxed to avoid false timeouts during debugging.

Example

csharp
services.AddSingleton(new RpcLimits(Debugger.IsAttached) {
    PrematureDisconnectTimeout = TimeSpan.FromSeconds(30),
    KeepAlivePeriod = TimeSpan.FromSeconds(10),
    KeepAliveTimeout = TimeSpan.FromSeconds(40),
});

Per-Peer Resource Caps

CallCountLimit and ObjectCountLimit (both added in v14.2) are backstops against a peer that opens calls or streams and never finishes them. Both are checked once per ObjectReleasePeriod rather than per call, so the actual count may overshoot by up to one cycle's worth before the peer is reset.

CallCountLimit defaults to int.MaxValue, i.e. disabled, and deliberately so: a Fusion server legitimately retains one inbound call per live client subscription, so 100K+ open inbound calls is normal operation rather than a leak. Set it only once you know your own ceiling. NoWait calls are never registered in either tracker, so they're invisible to this cap — lowering it does not throttle a NoWait flood.

ObjectCountLimit defaults to 65,536. Shared objects are released only after ObjectReleaseTimeout of silence, so a peer that abandons streams hovers near the cap — and therefore gets reset — roughly once per that timeout.

RpcPeerOptions

Configures RPC peer creation, connection handling, and lifecycle management.

Properties

PropertyTypeDefaultDescription
UseRandomHandshakeIndexboolfalseUse random handshake index values. Set to true for testing handshake issues.
PeerFactoryFunc<...>AutoFactory to create RpcPeer instances (RpcServerPeer or RpcClientPeer based on ref type)
ConnectionKindDetectorFunc<...>Uses RpcRef.ConnectionKindDetermines connection kind for a peer reference
TerminalErrorDetectorFunc<...>RpcReconnectFailedExceptionDetermines if an exception requires disconnection
ServerConnectionFactoryFunc<...>AutoCreates RpcConnection for server peers
ServerPeerShutdownTimeoutProviderFunc<...>33% of peer lifetime (3-15 min)Shutdown timeout for server peers
PeerRemoveDelayProviderFunc<...>0ms (server), 5min (client)Delay before removing peer from registry

Example

csharp
services.AddRpc().Configure<RpcPeerOptions>(options => {
    // Enable random handshake index for testing
    options.UseRandomHandshakeIndex = true;

    // Custom terminal error detection
    options.TerminalErrorDetector = (peer, error) =>
        error is RpcReconnectFailedException or ConnectionRefusedException;
});

RpcOutboundCallOptions

Configures outbound RPC call behavior including routing, timeouts, and rerouting.

Properties

PropertyTypeDefaultDescription
ReroutingDelaysRetryDelaySeqExp(0.1, 5)Exponential backoff for rerouting delays (0.1s to 5s). See Call Routing.
TimeoutsProviderFunc<...>Based on method kindProvides RpcCallTimeouts for specific methods
RouterFactoryFunc<...>Routes to RpcRef.DefaultCreates routers to select target peer. See Call Routing.
ReroutingDelayerFunc<...>Task.Delay()Async function to apply rerouting delays
HasherFunc<...>SHA256, 24-char Base64Hashes byte data for consistency checking

RpcCallTimeouts

Timeouts (and the one delay) used by TimeoutsProvider:

PropertyDefaultDescription
ConnectTimeoutTimeSpan.MaxValueHow long a call tolerates a peer that isn't connected - waiting for the connection before it is sent, and waiting out a disconnect after it is sent
RunTimeoutTimeSpan.MaxValueTimeout for call execution
DelayTimeout30 secondsCalls running longer than this are reported as delayed
CacheFallbackDelay0How long a call that can fall back to a value it already has - currently a remote compute call with a cached value - waits for a disconnected peer before serving that value

TimeSpan.MaxValue (= TimeSpanExt.Infinite) is the only "no timeout" / "never" value; zero always means "instantly", and a negative value throws. TimeSpanExt.AsTimeout() is the validating converter that turns a configured TimeSpan / TimeSpan? / double (seconds) / double? into one.

Every timeout expiry throws RpcTimeoutException, a transient TimeoutException whose TimeoutKind names the timeout that fired: Connect, Run, Delay (a delayed call aborted per DelayAction), Handshake, or KeepAlive. It is Unknown for an RpcTimeoutException transferred from the remote side, since only its type and message travel.

Every one of them can also be set per method via [RpcMethod(ConnectTimeout = ..., RunTimeout = ..., DelayTimeout = ..., CacheFallbackDelay = ...)] (all in seconds).

ConnectTimeout caps every wait for a connection, making no distinction between the first connection and a reconnect. It applies twice over a call's life: to the wait before the call is sent, and - once the call is sent - to the outage if the connection drops mid-call, measured from the disconnect. A call that survives the outage is resent as usual; one whose ConnectTimeout runs out first fails with RpcTimeoutException of Connect kind.

CacheFallbackDelay is the other deadline a call has while the peer is away. Both are owned by one per-outage watcher (RpcOutboundCallTracker.HandleDisconnect), which only delivers them - what each means is up to the call:

OnCacheFallbackDelayOnConnectTimeout
plain RPC calldeclines - nothing to servefails the call
compute call holding a cache entryserves that value, keeps the call aliveskipped: the call was served
compute call with nothing cached (NoCache, or a cold Cache call)declines - nothing to servefails the call

So a call that can serve something does, and is then exempt from ConnectTimeout - it has nothing left to fail, and its response is what validates what was served. A call that cannot falls straight through to ConnectTimeout, whose error is transient, so TransientErrorInvalidationDelay retries it. The fallback is offered whenever either deadline comes due, so setting CacheFallbackDelay above ConnectTimeout cannot turn a servable call into a failed one.

When the fallback fires, the cached value is served right away as an unsynchronized Computed - previously computed Cache-mode calls and all ReturnDefault calls. The pending call remains registered and carries the value's hash; once the peer is back it is resent and validates the value: a "match" reply confirms it in place, while a different result displaces it. That is why the default is 0 - waiting buys nothing when the served value gets re-validated anyway. A call issued while the peer is already disconnected takes the same path: it is registered and the watcher raises the fallback for it, so both cases are served identically.

A client peer whose next reconnect attempt is already scheduled past the deadline (RpcClientPeer.ReconnectsAt, e.g. because the app parks reconnects while the OS reports it offline) waits out neither of the two.

Default Timeouts by Method Type

Method TypeConnect TimeoutRun Timeout
Debug (debugger attached)Infinite300s
Query (API)InfiniteInfinite
Command (API)1.5s10s
Query (Backend)InfiniteInfinite
Command (Backend)300s300s

Example

csharp
services.AddRpc().Configure<RpcOutboundCallOptions>(options => {
    // Custom rerouting delays
    options.ReroutingDelays = RetryDelaySeq.Exp(0.5, 10); // 0.5s to 10s

    // Custom timeout provider
    options.TimeoutsProvider = (hub, methodDef) => new RpcCallTimeouts {
        ConnectTimeout = TimeSpan.FromSeconds(5),
        RunTimeout = TimeSpan.FromSeconds(30),
    };

    // Custom router (e.g., for sharding or load balancing)
    // See PartR-CallRouting.md for detailed examples
    options.RouterFactory = methodDef => args => RpcRef.Default;
});

RpcInboundCallOptions

Configures how inbound RPC calls are processed on the receiving end.

Properties

PropertyTypeDefaultDescription
ContextFactoryFunc<...>Creates RpcInboundContextFactory to create context for handling incoming calls

Example

csharp
services.AddRpc().Configure<RpcInboundCallOptions>(options => {
    // Custom context factory with additional setup
    options.ContextFactory = (peer, message, peerChangedToken) => {
        var context = new RpcInboundContext(peer, message, peerChangedToken);
        // Additional context setup...
        return context;
    };
});

RpcDiagnosticsOptions

Configures diagnostics, call tracing, and logging behavior.

Properties

PropertyTypeDefaultDescription
MustPropagateAmbientActivityContextbooltruePropagate Activity.Current to outbound call headers even when no RPC client activity exists
OpenCallMetricsPeriodProviderFunc<RpcPeer, TimeSpan>5 minutes for server peers, 1 minute for client peersMinimum interval between open-call table scans
CallTracerFactoryFunc<...>RpcDefaultCallTracer (server), null (client)Factory to create call tracers
CallLoggerFactoryFunc<...>Filters system KeepAlive callsFactory to create call loggers

Example

csharp
services.AddRpc().Configure<RpcDiagnosticsOptions>(options => {
    // Custom call tracer
    options.CallTracerFactory = (hub, methodDef) =>
        new MyCustomCallTracer(methodDef);

    // Custom call logger that logs everything
    options.CallLoggerFactory = (hub, methodDef) =>
        new RpcCallLogger(hub, methodDef);
});

RpcRegistryOptions

Configures RPC service and method definition creation.

Properties

PropertyTypeDefaultDescription
ServiceDefFactoryFunc<...>Creates RpcServiceDefFactory to create service definitions
MethodDefFactoryFunc<...>Creates RpcMethodDefFactory to create method definitions
ServiceScopeResolverFunc<...>"Backend" or "Api"Determines service scope

Example

csharp
services.AddRpc().Configure<RpcRegistryOptions>(options => {
    // Custom service scope resolution
    options.ServiceScopeResolver = (hub, serviceType) =>
        serviceType.Name.StartsWith("IInternal") ? "Backend" : "Api";
});

RpcWebSocketClientOptions

Configures WebSocket-based RPC client connections.

Properties

PropertyTypeDefaultDescription
RequestPathstring"/rpc/ws"WebSocket endpoint path for API calls
BackendRequestPathstring"/backend/rpc/ws"WebSocket endpoint path for backend calls. Must NOT be publicly exposed!
SerializationFormatParameterNamestring"f"Query parameter for serialization format
ClientIdParameterNamestring"clientId"Query parameter for client ID
ReconnectProofCounterParameterNamestring"c"Query parameter for the reconnect proof counter
ReconnectProofParameterNamestring"p"Query parameter for the reconnect proof itself
UseAutoFrameDelayerFactoryboolfalseEnable automatic frame delaying
HostUrlResolverFunc<...>Uses peer.Ref.HostInfoResolves host URL from peer reference
ConnectionUriResolverFunc<...>HTTP→WS conversionCreates WebSocket connection URI
WebSocketTransportOptionsFactoryFunc<...>AutoCreates RpcWebSocketTransport options
WebSocketOwnerFactoryFunc<...>ClientWebSocketCreates WebSocket instances
FrameDelayerFactoryFunc<...>NoneFrame delaying mechanism

Example

csharp
services.AddRpc().Configure<RpcWebSocketClientOptions>(options => {
    // Custom endpoint paths
    options.RequestPath = "/api/rpc";
    options.BackendRequestPath = "/internal/rpc"; // Must NOT be publicly exposed!

    // Custom host URL resolution
    options.HostUrlResolver = peer => {
        // Load balancer logic, etc.
        return "https://api.example.com";
    };

    // Enable frame delaying for high-latency connections
    options.UseAutoFrameDelayerFactory = true;
});

Warning: BackendRequestPath must never be publicly exposed. It should only be accessible between backend services within your infrastructure.

RpcWebSocketServerOptions

Configures WebSocket-based RPC server endpoints.

Properties

PropertyTypeDefaultDescription
ExposeBackendboolfalseWhether to expose backend services via WebSocket. Use with caution!
RequestPathstring"/rpc/ws"WebSocket endpoint path for API calls
BackendRequestPathstring"/backend/rpc/ws"WebSocket endpoint path for backend calls. Must NOT be publicly exposed!
SerializationFormatParameterNamestring"f"Query parameter for serialization format
ClientIdParameterNamestring"clientId"Query parameter for client ID
ReconnectProofCounterParameterNamestring"c"Query parameter for the reconnect proof counter
ReconnectProofParameterNamestring"p"Query parameter for the reconnect proof itself
RequireReconnectProofboolfalseRequire a valid reconnect proof from every client reconnecting to a live server peer
OriginValidatorRpcWebSocketServerOriginValidatorRpcWebSocketServerOriginValidators.AllowAllDecides from (server, context, origin) whether the upgrade may proceed; rejects with 403 before any peer is created
WarnOnUnvalidatedOriginbooltrueLog one startup warning when nothing validates the upgrade request's Origin
ConfigureWebSocketRpcWebSocketServerAcceptContextFactoryEmpty contextCreates the WebSocket accept context per connection from (server, context, rpcRef) — e.g. to enable compression selectively (.NET 6+)

Example

csharp
services.AddRpc().Configure<RpcWebSocketServerOptions>(options => {
    // Expose backend services (be careful with security!)
    options.ExposeBackend = true;

    // Custom endpoint paths (must match client)
    options.RequestPath = "/api/rpc";
    options.BackendRequestPath = "/internal/rpc"; // Must NOT be publicly exposed!
});

Warning: BackendRequestPath must never be publicly exposed. Ensure this endpoint is only accessible within your internal network or via service mesh. If ExposeBackend is true, take extra care to secure this endpoint.

OriginValidator

The WebSocket handshake is exempt from CORS and from preflight, so a CORS policy does not protect the RPC endpoint. If connections carry an ambient credential — a Fusion session cookie, most notably — any page the victim visits can otherwise open one and speak RPC as the victim (cross-site WebSocket hijacking).

RpcWebSocketServerOriginValidators ships three ready-made validators:

ValidatorBehavior
AllowAllAccepts any origin. The default, so nothing breaks on upgrade
SameOriginAccepts only an origin whose host and port match the request's Host header
Allow(params string[] origins)Accepts only the listed origins
csharp
rpc.AddWebSocketServer().Configure(_ => RpcWebSocketServerOptions.Default with {
    OriginValidator = RpcWebSocketServerOriginValidators.SameOrigin,
});

// Or, when the client is served from elsewhere:
rpc.AddWebSocketServer().Configure(_ => RpcWebSocketServerOptions.Default with {
    OriginValidator = RpcWebSocketServerOriginValidators.Allow(
        "https://app.example.com",
        "capacitor://localhost", // Mobile WebViews send scheme-specific origins...
        "null"),                 // ...or a literal "null"
});

All three allow a request that carries no Origin header. Browsers always send it on a handshake and page scripts cannot forge it (it is a forbidden header), so only non-browser clients can omit it — and omitting it gains them nothing, since the attack depends on the victim's browser attaching the victim's cookie. Non-browser clients (the .NET RpcWebSocketClient, CLI tools, backend peers) therefore keep working under SameOrigin and Allow(...).

SameOrigin compares the origin's host and port against the request's Host header, and deliberately ignores the scheme: behind a TLS-terminating proxy Request.Scheme is http unless forwarded headers are configured, while the browser still reports https. It rejects the opaque null origin and any non-http(s) scheme — use Allow(...) for WebView origins.

Note: ASP.NET Core's WebSocketMiddleware has its own, independent gate: when WebSocketOptions.AllowedOrigins is non-empty it returns 403 for a mismatched Origin before the endpoint runs (an empty list allows everything, and an absent Origin is allowed even when the list is non-empty). The two mechanisms compose — the middleware runs first. Fusion's own validator adds what the platform list cannot do: it works on OWIN (ActualLab.Rpc.Server.NetFx, which has no equivalent at all), it applies to the RPC endpoint alone rather than every WebSocket in the app, and it can express "same origin as this request" instead of a fixed list.

WarnOnUnvalidatedOrigin

Both WebSocket servers (ActualLab.Rpc.Server and ActualLab.Rpc.Server.NetFx) log a single warning from their constructor when nothing validates the Origin — i.e. when OriginValidator is still AllowAll and, on ASP.NET Core, WebSocketOptions.AllowedOrigins is empty as far as DI can see. The message links to this page.

Turn it off for a server whose connections carry no ambient credentials — a backend-only endpoint, or one where every call authenticates itself:

csharp
rpc.AddWebSocketServer().Configure(_ => RpcWebSocketServerOptions.Default with {
    WarnOnUnvalidatedOrigin = false,
});

Note that options passed straight to app.UseWebSockets(new WebSocketOptions { … }) never reach DI, so a host that set AllowedOrigins that way is still warned; turning the warning off is the right answer there.

RequireReconnectProof

The connect URL's clientId alone used to select which server peer a connection attaches to, and the incumbent connection was disconnected before anything was verified. A clientId is unguessable, but it travels in a URL — so it lands in proxy logs, browser history and Referer chains, and whoever reads one could loop the request to keep the victim permanently offline, or replay it and inherit the victim's server-side peer state.

Since v14.2 the server mints a per-peer CSPRNG secret and delivers it inside its RpcHandshake (the new Secret member) — over the established connection, never in a URL. Every subsequent connect then carries two query parameters:

ParamValue
ca monotonic counter, canonical decimal, incremented once per connect attempt
pBase64Url_NoPad(HMAC_SHA256(UTF8(secret), UTF8(clientId + "\n" + c)))

Verification (RpcReconnectProof.TryVerify) runs before the peer is looked up, before the WebSocket is accepted and before any disconnect, so a failed proof is a bare 403: no socket accepted, no peer created, incumbent untouched. All three server endpoints — ASP.NET Core WebSocket, HTTP/2 and OWIN — share that one policy function, so they can't drift apart.

RequireReconnectProof defaults to false, and the gate still protects by default:

  • An unknown clientId needs no proof — there's nothing to hijack yet, and it's what a client reaching a different replica legitimately sends.
  • A clientId whose peer has never seen a valid proof takes the legacy path, so genuinely old clients keep working.
  • A peer that has proven possession at least once may not downgrade: an absent c/p pair is refused regardless of this option, because that is exactly how an attacker would strip the proof. A new client is therefore covered from its second connect onwards.
  • A present-but-invalid pair is always rejected.

Set it to true only once every client that can still reconnect to a live server peer speaks the protocol, and only behind sticky routing: a reconnect that lands on a replica which already holds a peer for that clientId — but issued a different secret — is rejected until that peer expires (3–15 min). A host with a custom ConnectionUriResolver that supplies a persistent clientId must not enable it unless it persists the secret too. The option is bindable from configuration, so it can be rolled back without a redeploy.

The client side is automatic: RpcWebSocketClient appends c and p as soon as it holds a secret. See Reconnect proof for the TypeScript client, which implements the same construction and degrades to the legacy URL where crypto.subtle is unavailable.

RpcTestClientOptions

Configures test client for RPC testing with in-memory channels.

Properties

PropertyTypeDefaultDescription
SerializationFormatKeystring""Serialization format identifier
ChannelOptionsChannelOptionsBoundedChannelOptions(500)Configuration for test message channels
ConnectionFactoryFunc<...>Twisted channel pairFactory to create test channel pairs

Example

csharp
// In tests
services.AddRpc().Configure<RpcTestClientOptions>(options => {
    // Use specific serialization format for testing
    options.SerializationFormatKey = "json";
});

Configuration Patterns

Basic Configuration

csharp
var services = new ServiceCollection();
services.AddRpc()
    .Configure<RpcPeerOptions>(o => { /* ... */ })
    .Configure<RpcOutboundCallOptions>(o => { /* ... */ })
    .Configure<RpcWebSocketClientOptions>(o => { /* ... */ });

Server-Side Configuration

csharp
builder.Services.AddRpc()
    .Configure<RpcWebSocketServerOptions>(options => {
        options.ExposeBackend = false; // Security: don't expose internal services
        options.RequestPath = "/rpc/ws";
    });

// In middleware pipeline
app.MapRpcWebSocketServer();

Client-Side Configuration

csharp
services.AddRpc()
    .Configure<RpcWebSocketClientOptions>(options => {
        options.HostUrlResolver = peer => configuration["ApiUrl"];
    })
    .Configure<RpcOutboundCallOptions>(options => {
        options.ReroutingDelays = RetryDelaySeq.Exp(1, 30); // Longer rerouting delays
    });