Skip to content

Connections

Every protocol runs on a connection unless it picks the lockstep datagram framing, in which case it gets a client and server instead. This page uses this schema:

protocol Chat {
    function send(text: string) -> u64;
    function count() -> u64;
}

@provider = Client
protocol Events {
    function message(text: string) -> bool;
    function ping();
}

Serving every protocol

The package gets one serve, with a Services struct holding a field per protocol the server provides. Leaving one out doesn't compile:

use my_package::{connect, serve, ClientServices, Services};
use my_package::chat::ChatSender;

// The side that accepts: handshake, provide every protocol, serve until
// the peer leaves.
serve(transport, Services { chat: ChatImpl })?;

// The side that dials: handshake, provide the client's protocols, and call
// the server's through their senders.
let mut conn = connect(transport, ClientServices { events: EventsImpl::default() })?;
let sent = ChatSender::new(&mut conn).send("hi")?;
  • accept(transport, Services { .. }) is serve without the serving: it returns the connection, for a server that calls the client first or serves one request at a time.
  • ClientServices holds the protocols the dialling side provides (@provider = Client or Multiple). With none, connect takes only the transport.
  • Field names are the protocol's name in snake case (CharacterInventory is character_inventory). Where two schemas declare a protocol of the same name, both are prefixed with their namespace.
  • The format is the package's Format (Framing & wire format), and the framing and handshake are the protocols'. When the protocols on connections disagree on either, no one connection carries them all, so these functions aren't generated and comline generate warns why.

In lib mode they're at the crate root; in code mode, in connection.rs beside the schema files.

What codegen emits

pub trait Chat {
    fn send(&self, peer: &mut impl Peer, text: &str) -> Result<u64, Fault<ChatSendError>>;
    fn count(&self, peer: &mut impl Peer) -> Result<u64, Fault<ChatCountError>>;
}

pub struct ChatDispatcher<S>(pub S);       // PROTOCOL, provide_on(&mut Connection)
pub struct ChatSender<'p, P: Peer>(pub &'p mut P);

pub trait Events { /* the same shape */ }
pub struct EventsDispatcher<S>(pub S);
pub struct EventsSender<'p, P: Peer>(pub &'p mut P);

// Once per package:
pub type Format = comline_runtime::format::Packed;
pub const CONNECTION_IR_HASH: u64 = 0x…;   // checked by the handshake
pub struct Services<Chat> { pub chat: Chat }
pub struct ClientServices<Events> { pub events: Events }
pub fn accept(…); pub fn serve(…); pub fn connect(…);

It differs from a client and server in three ways:

  • Handlers take the peer first: the other end of the connection the call came in on. Through it a handler calls back (EventsSender::new(peer)), reads peer.id(), and keeps data on the connection with peer.extensions().
  • A sender replaces the client. ChatSender has one method per function, as ChatClient would, over anything that implements Peer: a Connection, or a handler's peer. Calls return CallError as a client's do; @timeout_ms applies; one-way functions return Result<(), RuntimeError>.
  • CONNECTION_IR_HASH covers every schema in the package. It also fixes each protocol's number (ChatDispatcher::PROTOCOL), its position among the package's protocols.

Protocols on datagram in the same package keep their Client and Server, and so do jsonrpc ones unless the package has a JSON-RPC protocol on a connection (see Over JSON-RPC).

By hand

serve is a few lines over the runtime's Connection, which is also how to pick another format for one connection:

use comline_runtime::connection::Connection;

let mut conn = Connection::accept(transport, Format::default(), CONNECTION_IR_HASH)?;
ChatDispatcher(ChatImpl).provide_on(&mut conn);
conn.serve()?;                              // until the transport closes

serve_one() handles a single frame instead, returning Ok(false) once the transport has closed. Between frames, this side can call the client on its own:

conn.serve_one()?;
EventsSender::new(&mut conn).message("pushed")?;

The dialling side is Connection::connect with the same arguments. While a call waits for its reply, the connection serves whatever the server sends first, such as the Events::message call its handler makes. A request to a protocol this side doesn't provide is answered with a fault.

Over JSON-RPC

A JSON-RPC protocol the client provides runs on a connection too, and then so do the package's other JSON-RPC protocols (the rules). Open the connection with JsonRpcFraming, which the package's serve and connect do for you; the rest of this page is unchanged:

use comline_runtime::framing::JsonRpcFraming;
use comline_runtime::handshake::ComlineHandshake;

let mut conn = Connection::accept_with(transport, Json, JsonRpcFraming, &mut ComlineHandshake, CONNECTION_IR_HASH)?;
// The dialling side:
let mut conn = Connection::connect_with(transport, Json, JsonRpcFraming, &mut ComlineHandshake, CONNECTION_IR_HASH)?;

An AsyncConnection opens with AsyncConnection::handshake(transport, Json, JsonRpcFraming, CONNECTION_IR_HASH).

  • Each call table holds method names: ["send", "count"], or under method_names = qualified, ["chat.Events/message", "chat.Events/ping"]. A request is routed to the provided protocol whose table has its method.
  • A one-way call is a notification, sent with no id. A request without one gets no reply, even if its function returns a value.

A handler calling back

struct Sent(u64);

impl Chat for ChatImpl {
    fn send(&self, peer: &mut impl Peer, text: &str) -> Result<u64, Fault<ChatSendError>> {
        let shown = EventsSender::new(peer).message(text).map_err(|_| Fault::Internal)?;
        let sent = peer.extensions().get_mut::<Sent>().map(|s| { s.0 += 1; s.0 });
        let sent = sent.unwrap_or_else(|| { peer.extensions().insert(Sent(1)); 1 });
        Ok(sent)
    }
    // …
}

Extensions holds one value per type (insert, get, get_mut, remove). It belongs to the connection, so Sent counts this client's messages and no one else's.

Threads

A Connection is blocking and does one thing at a time. A request is handled to the end before the next frame is read, and a call made while serving finishes before the request that made it. To call the peer from another task while the connection serves, generate for the async driver.

Async

With async = true in comline.toml, connection protocols are generated for the runtime's AsyncConnection instead, on any executor:

pub trait Chat: Send + Sync + 'static {
    fn send(&self, peer: &impl AsyncPeer, text: &str)
        -> impl Future<Output = Result<u64, Fault<ChatSendError>>> + Send;
    // …
}

pub struct ChatAsyncDispatcher<S>(pub S);       // PROTOCOL, provide_on(&mut AsyncConnection)
pub struct ChatAsyncSender<'p, P: AsyncPeer>(pub &'p P);

A handler is written as an async fn, and awaits its calls back:

impl Chat for ChatImpl {
    async fn send(&self, peer: &impl AsyncPeer, text: &str) -> Result<u64, Fault<ChatSendError>> {
        let shown = EventsAsyncSender::new(peer).message(text).await.map_err(|_| Fault::Internal)?;
        // …
    }
}

The connection is a future the application spawns; its handles call the peer from any task. The generated crate enables the runtime's async feature; in code mode, the file names the feature to add.

The package's serve, accept and connect are async too. accept and connect return the connection not yet running, so the caller can take a handle() before spawning it:

let server = tokio::spawn(serve(acceptor, Services { chat: ChatImpl }));

let client = connect(dialler, ClientServices { events: EventsImpl::default() }).await?;
let to_server = client.handle();
tokio::spawn(client.run());

let sent = ChatAsyncSender::new(&to_server).send("hi").await?;

Requests are handled in order, except that while every running handler waits on a call to the peer, the next one starts: it may be that call's nested request. conn.concurrent(n) lets several run at once, and conn.set_timer(…) gives @timeout_ms a clock. The runtime's tokio feature adds TCP (async_connection::tokio::Tcp) and a timer.

Protocols that aren't on a connection keep their blocking Client and Server either way.

Without generated code

Connection::provide(protocol, dispatcher) registers any Dispatch. One that needs the peer implements Dispatch::dispatch_with, whose default calls dispatch. Connection implements Peer, whose call, notify and call_with_timeout take the protocol number. Underneath, connection::State is the I/O-free core: it encodes requests and replies, and turns frames into Events (a request, a reply, or a stale reply to an abandoned call).