Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

std::rpc reference

Transports, the generated service surface, errors, and connection state. Concepts and usage: the services guide. Most apps touch only the generated client, RpcError, and ConnectionState; everything else here is the machinery those sit on.

The generated surface ([service])

For [service(FooClient)] struct Foo with [rpc] methods and [expose] signal fields, the macro generates:

// client side
FooClient::connect(url: str, codec: Codec): Result<FooClient<SocketTransport>, RpcError>
client.some_rpc(args…): Result<T, RpcError>     // per [rpc] method; implicitly awaited
client.some_signal: RemoteSource<T>             // per [expose] field; a typed mirror (below)
client.transport: SocketTransport               // connection state lives here

// server side
foo.dispatcher(): Dispatcher                    // the method table
dispatcher.into_protocol(codec: Codec): RpcProtocol   // what Service::new takes

connect accepts a relative url ("/") in the browser; it dials the same host over WebSocket, waits for the server’s announcement, and verifies the contract hash: a drifted server fails the connect with RpcError::Contract.

Mirrors: RemoteSource<T>

struct RemoteSource<T> { … }

impl RemoteSource<type T> {
	fun get(self): Option<T>                              // passive: the cache, `None` before the first update
	fun status(self): SignalCell<Status>                      // passive: `Waiting` until a value has arrived, then `Ready`
	fun or(self, initial: T): SignalCell<T>                   // counted, owner-scoped: `initial` until the first update
	fun map<U>(self, transform: sync |Option<T>| U): SignalCell<U>   // counted, owner-scoped: the `Option` confronted once
	[must_use]
	fun sub(self, observer: |T| void): Subscription       // counted, manual: present values; dispose to release
}

[derive(PartialEq, Debug)]
enum Status { Waiting, Ready }

A mirror holds Option<T>None until the first Update lands — and subscribes by demand: every or, map, and sub takes a counted lease on the channel. The 0→1 lease sends Subscribe (the server answers with the current value at once); the 1→0 release sends Unsubscribe, deferred to the end of the ambient turn so a same-turn re-subscribe (a view re-rendering in place) sends nothing. A second watcher on an open channel sends no frame. On reconnect a watched mirror (count > 0) re-subscribes on its fresh channel; an unwatched one does not.

or and map hand the lease to the ambient owner (the enclosing view, or a run_with_owner), so it is released at unmount; calling either where no owner is ambient is a compile error (context coverage), by design — a network subscription must have a releaser. sub is the manual form for code with no owner: you hold the Subscription and dispose it.

get and status open nothing. A status observer alone never sees Waiting → Ready: status reports, it does not ask; until something that renders the value subscribes, the mirror stays Waiting, and that is correct — the channel was never opened.

The SignalCell<T> that or/map return is a local derivative: writing it writes nothing back (the server owns the source) and the next update overwrites it. An empty-list initial needs no annotation — the [] takes its element type from the mirror (let notes = client.notes.or([]); is a SignalCell<List<Note>>).

Errors

[derive(Wire, Debug)]
enum RpcError {
	Transport(str),   // couldn't reach / lost the server ("not connected", "connection lost")
	Decode(str),      // reply didn't parse
	Remote(str),      // the handler failed
	Contract(str),    // connect-time shape mismatch (old client vs new server)
	Unauthorized,
}

Infrastructure failures only: an application “not found” belongs in the rpc’s own return type (Option<Task>), not here.

Connection state

enum ConnectionState { Connected, Reconnecting, Closed }

impl SocketTransport {
	fun connection_state(self): SignalCell<ConnectionState>
	fun on_reconnect(self, hook: async || void)
}

The reconnect lifecycle (automatic): on drop → Reconnecting, in-flight calls reject with Transport("connection lost"), new calls fail fast with Transport("not connected"); dial with backoff (250 ms doubling, 4 s cap, 10 attempts); on success → contract re-check, mirrors re-attach and resync, Connected. Nothing is ever silently retried; retry is the app’s decision.

Closed is terminal, and three things reach it: the attempt budget runs out; the re-dialled server’s contract has drifted (it redeployed a different surface, so typed mirrors would decode against a shape they were not built for); or the re-attach itself is refused — the server answers, but will not hand back channel ids. The last two close the socket rather than staying Connected, because a mirror that cannot be rebound is pointed at the previous connection’s dead channels: it would never update again, and the socket would say nothing was wrong. Bind Closed and offer the restart; that decision is the app’s.

On every path that reaches Closed the client disposes itself: its update routes are emptied and its transport forgets it. The state is what decides, not the reason for it — a spent budget releases exactly as a drifted contract does. Closed is terminal, so nothing on that socket can ever reach those mirrors again and nothing keeps holding them: the mirrors read their last value forever, exactly as they did before, and the graph behind them is released instead of wedged open. What does not dispose is a connection that is merely slow — a redial in progress, or a server unreachable when the re-attach runs — because that socket is Reconnecting, not Closed: it keeps trying, and its mirrors resync when it succeeds.

on_reconnect is where that decision goes. Hooks run after each successful re-dial, awaited in order, and the generated client registers its own mirror re-attach when it connects — so a hook you register runs after the mirrors have resynced, which connection_state cannot tell you: the state flips to Connected one beat earlier, because the re-attach’s own rpc call needs a usable transport first. Bind the signal for a banner; use the hook for anything that needs current mirrors.

client.transport.on_reconnect(|| title.repush());

Keep a hook short — it runs inside the reconnect loop’s own extent, so a long round-trip inside one holds the reconnect open behind it.

Transports

trait Transport {
	fun call(self, request: Frame): Task<Result<Frame, str>>;
}
TransportWireUse
SocketTransportWebSocket (reconnecting)what connect gives you, the production client transport
HttpTransportone POST per callstateless calls, no mirrors
LocalTransportin-processtests: client and service in one process

Below SocketTransport sits SocketDuplex (the reconnect-surviving socket: pending-call registry, inbound dispatch, on_reconnect hooks) and the DuplexTransport machinery (duplex_pair, bridge, connect_split for the SSE/split fallback). App code doesn’t construct these; the generated connect does.

Disposing a reactive session (ReactiveServer/ReactiveClient) also clears its end’s inbound handler — DuplexEnd::clear_on_frame, the teardown half of on_frame. Without it the wire kept the closure that captured the session, so a closed connection stayed reachable from its transport; the server half runs per disconnect, through drop_session.

fun connect_socket(url: str): Result<SocketDuplex, str>   // dial + announcement (backoff)
impl SocketDuplex {
	fun transport(self): SocketTransport
}

Server plumbing (std::rpc_server, process layer)

impl Service {
	fun new(protocol: RpcProtocol): Service   // mounted at "/"
	fun at(own self, prefix: str): Service    // mount elsewhere, e.g. "/admin/"
	fun on_connect(own self, handler: |i32, DuplexEnd| void): Service
	fun on_disconnect(own self, handler: |i32| void): Service
}
impl ServerBuilder {
	fun with_service(own self, service: Service): ServerBuilder   // repeatable
}

A service is WebSocket upgrade + per-connection session registration (mirror attach/detach) + rpc dispatch. Each handler runs in a turn (AtEnd). ServerBuilder::with_service installs those routes and the handshake on a Server::builder() chain, answering before on_request, so a page and a service sit on one builder instead of one replacing the other. It is repeatable — a second service goes on its own mount (Service::new(protocol).at("/admin/")), picked by longest mount and independent of call order.

Service::new(protocol) wires the runtime session registry as the connection lifecycle — what Client::connect’s generated __attach answers from; on_connect/on_disconnect replace it for apps holding their own per-connection state (connection-scoped auth, an app-written attach). The {mount}rpc route is the server side of std::rpc’s HttpTransport (HttpTransport { url = "http://host:port/rpc" }), and on_request answers every path no service claims — the app shell, usually; serving the build’s own artifacts is ServerBuilder::serve_build’s job, on the same builder. Details: Services & RPC and the process reference.

One matching rule is worth knowing: a service claims a path segment — its route exactly, or its route followed by ? — so /rpc does not shadow an application’s /rpcs or /rpc-docs.

Envelope & codec layer

Frame is the codec-agnostic unit (std::wire); encode_request / open_request / encode_reply read and write the rpc envelope ({"method": …, "args": […]} on the json codec). Codec comes from json_codec() (std::json) or binary_codec() (std::binary); both ends must use the same one. You only meet this layer when implementing a custom transport or protocol bridge.