Bifrost RPC speaks JSON-RPC 2.0 over a Unix domain socket (AF_UNIX file socket), using newline-delimited JSON (NDJSON): one UTF-8 JSON object per line, no pretty-printing.
- The host (
RpcHost) binds and listens on a single socket path. - Each instance (primary or shadow) connects with its own stream via
RpcPeer. - Instances do not talk to each other.
- Both sides communicate through one concrete type:
RpcSession(Bifrost.Rpc.Abstractions). RpcHostandRpcPeerboth implementIRpcEndpoint, so contract registration is identical on either side.
| Direction | Behavior |
|---|---|
| Host → instances (request) | Host calls through IRpcEndpoint.GroupSession, a multiplex RpcSession that broadcasts to all connected instances and returns only the primary response. Shadow responses are drained and discarded. |
| Host → instances (notification) | Broadcast to all; no response expected. |
| Instance → host (request) | Handled on that instance's RpcSession; reply is point-to-point. |
| Instance → host (notification) | Host handles; no reply. |
Exactly one instance is configured as primary. There is no automatic failover in v1. GroupSession resolves its members per call, so it stays valid across reconnects and surfaces a missing primary as error -32001 at invoke time.
Define an interface that extends IRpcContract. The source generator emits a proxy, an IRpcBinding<T>, and a module initializer that registers the binding in RpcBindings:
public interface IWorkRunner : IRpcContract
{
Task<RunResult> Run(RunRequest request, CancellationToken ct = default);
}- RPC method name:
{InterfaceName}.{MethodName}verbatim (IWorkRunner.Run). - Override with
[RpcMethod("custom.name")]. - Return-type convention:
Task/ValueTask→ JSON-RPC notification (fire-and-forget)Task<T>/ValueTask<T>→ JSON-RPC request (await response)Task<Unit>/ValueTask<Unit>→ awaitable request with no result payload
- Params are a JSON object with named properties matching parameter names.
Any assembly that declares contracts must reference Bifrost.Rpc.Generators as an analyzer. That happens automatically for package consumers; inside this repo it is an explicit ProjectReference with OutputItemType="Analyzer", because analyzers do not flow transitively through project references.
Both sides use the same two registrations. AddRpcHost / AddRpcPeer decides which endpoint they bind to.
// Host process
services.AddRpcHost(options =>
{
options.Instances =
[
new RpcInstanceOptions { Id = "primary", Role = RpcRole.Primary, Executable = "./runner" },
new RpcInstanceOptions { Id = "shadow", Role = RpcRole.Shadow, Executable = "./runner-next" },
];
});
// Peer process — reads the Rpc configuration section
services.AddRpcPeer();| Registration | Effect |
|---|---|
AddRpc<TContract>() |
IRpc<TContract> (singleton) calls the group: broadcast, primary response. IEnumerable<IRpc<TContract>> (transient) yields one handle per currently connected instance. |
AddRpcHandler<TContract, TImplementation>() |
Registers the implementation and serves inbound calls with it. Use AddRpcHandler<TContract>() when the implementation is already registered. |
sealed class Scheduler(IRpc<IWorkRunner> rpc)
{
readonly IWorkRunner _runner = rpc.Object; // generated proxy over the socket
}Handlers are attached to each session before its read loop starts, so an inbound call can never race registration.
The host passes connection info as configuration on the peer command line. A generic host loads the same keys from environment variables (Section__Key) and from appsettings.json. Role defaults to shadow.
| Purpose | Configuration key | Command-line switch | Environment variable |
|---|---|---|---|
| Socket path | Rpc:SocketPath |
--Rpc:SocketPath <path> |
Rpc__SocketPath |
| Instance id | Rpc:InstanceId |
--Rpc:InstanceId <id> |
Rpc__InstanceId |
| Role | Rpc:Role |
--Rpc:Role primary|shadow |
Rpc__Role |
After connect, the peer must send:
{
"jsonrpc": "2.0",
"method": "bifrost.ready",
"params": { "instanceId": "primary", "role": "primary" },
"id": "1"
}The host replies with a normal JSON-RPC result (e.g. {"ok":true}). The host rejects unknown instanceId values. RpcPeer retries the dial until its connect timeout, since a launched process can be ready before the host finishes binding.
| Method | Direction | Kind | Notes |
|---|---|---|---|
bifrost.ready |
peer → host | request | Required after connect |
bifrost.shutdown |
host → peer | notification | Host is stopping; peer should exit |
bifrost.ping |
either | request | Liveness; answered with {"ok":true} |
These are reserved; contracts must not claim them. RpcMethods holds the constants.
- One JSON-RPC message per line, terminated by
\n - Request
idmay be string or number; multiplex broadcasts reuse the sameidstring per connection - Correlation is always per-connection
Standard JSON-RPC codes, plus:
| Code | Meaning |
|---|---|
-32001 |
Primary unavailable |
-32002 |
Endpoint not ready |
-32003 |
Process failed |
- Host sends
bifrost.shutdownnotification on each connection AddRpcPeerwires that toIHostApplicationLifetime.StopApplication()- Host waits briefly, then kills remaining processes
- Host unlinks the socket file
| Package | Role |
|---|---|
Bifrost.Rpc.Abstractions |
IRpcContract, IRpc<T>, IRpcBinding<T>, RpcBindings, IRpcEndpoint, IRpcSubscription, RpcSession, Unit, [RpcMethod], AddRpc / AddRpcHandler |
Bifrost.Rpc |
RpcHost (listen, launch, supervise) and RpcPeer (connect), options, hosted services, AddRpcHost() / AddRpcPeer() |
Bifrost.Rpc.Generators |
Source generator (analyzer) |
A contracts-only library can reference Bifrost.Rpc.Abstractions plus the generator without pulling in process supervision. Both runtime packages live in the single Bifrost.Rpc namespace, so consumers need one using.
Non-.NET peers only need to implement connect + NDJSON JSON-RPC as described above.