0.15 — GATs, CapabilityServerSet, and async packing

03 Nov 2022

Today I am releasing version 0.15.0 of the capnproto-rust crates: capnp, capnpc, capnp-futures, and capnp-rpc. It’s been a while – almost two years since the 0.14.0 release! So what’s new?

Generic Associated Types

Say we have a Cap’n Proto schema that defines a struct type Foo:

struct Foo {
  id @0 :UInt64;
  payload @1 :Data;
}

When we pass this to the capnpc-rust code generator, it gives us declarations for foo::Reader<'a> and foo::Builder<'b> types. These types act as “synthetic references”, allowing us to read a capnp-encoded Foo value directly from a byte buffer without copying it into an intermediate structure. The foo::Reader<'a> type acts like an immutable (shared) reference &'a Foo, and the foo::Builder<'a> type acts like a mutable reference &'a mut Foo.

Very well, but what if we now want to define a generic container type for a Cap’n Proto message, whose contents can be borrowed either immutably or mutably? Something like:

struct MessageContainer<T> where T: ??? {
    message: capnp::message::Builder<capnp::message::HeapAllocator>,
    marker:  core::marker::PhantomData<T>,
}

impl <T> MessageContainer<T> where T: ??? {
    fn get(&self) -> ??? { ... }
    fn get_mut(&mut self) -> ??? { ... }
}

We want to be able to plug Foo (or any other Cap’n Proto struct type) in for T here. How do we fill in the ??? to make this work?

The key is that, in addition to foo::Reader and foo::Builder, the capnpc-rust code generator also generates a foo::Owned type, meant as a stand in for Foo itself (which cannot be directly represented in Rust). The type foo::Owned has the following impl:

impl capnp::traits::Owned for foo::Owned {
    type Reader<'a>: foo::Reader<'a>;
    type Builder<'a>: foo::Builder<'a>;
}

where capnp::traits::Owned is defined as

pub trait Owned {
    type Reader<'a>: FromPointerReader<'a> + SetPointerBuilder;
    type Builder<'a>: FromPointerBuilder<'a>;
}

Then we can fill in MessageContainer as follows:

struct MessageContainer<T> where T: capnp::traits::Owned {
    message: capnp::message::Builder<capnp::message::HeapAllocator>,
    marker:  core::marker::PhantomData<T>,
}

impl <T> MessageContainer<T> where T: capnp::traits::Owned {
    fn get(&self) -> T::Reader<'_> { ... }
    fn get_mut(&mut self) -> T::Builder<'_> { ... }
}

Notice that the lifetime parameters on Owned::Reader and Owned::Builder make them generic associated types, a newly stablized feature of Rust.

How it worked before GAT

In previous versions of capnproto-rust, we needed to hoist the lifetime parameter <'a> to the top of the declaration of capnp::traits::Owned, like this:

pub trait Owned<'a> {
    type Reader: FromPointerReader<'a> + SetPointerBuilder;
    type Builder: FromPointerBuilder<'a>;
}

Then, usages of the trait often needed higher rank trait bounds (i.e. for<'a>...), like this:

struct MessageContainer<T> where T: for<'a> capnp::traits::Owned<'a> {
    message: capnp::message::Builder<capnp::message::HeapAllocator>,
    marker:  core::marker::PhantomData<T>,
}

The for<'a> syntax makes this trait look more complicated than it actually is, so it’s good that we are finally able to remove it!

CapabilityServerSet

Consider the following (over)simplified version of Sandstorm’s web publishing interface:

interface BlobHandle {}

interface BlobWriter {
  write @0 (chunk: Data);
  done @1 () -> (handle :BlobHandle);
}

interface WebSitePublisher {
  createBlob @0 () -> (writer :BlobWriter);
  set @1 (path :Text, blob :BlobHandle);
}

To add some piece of content to a web site, an consumer of this API would do the following:

  1. Call createBlob().
  2. Write to the blob using BlobWriter.write().
  3. Call BlobWriter.done() to get a BlobHandle.
  4. Pass the BlobHandle to WebSitePublisher.set() for each path that should serve the content.

This flow allows uploads to be streamed (step 2), and it allows a single piece of content to be pushed to multiple paths (step 4) without duplicated work.

But how is the server supposed to implement WebSitePublisher.set()? The BlobHandle that it receives does not have any methods, so how can anything meaningful be done with it?

Let’s first translate the question into Rust code. The capnpc-rust-generated code for the above schema will contain Client structs blob_handle::Client, blob_writer::Client, web_site_publisher::Client, and Server traits blob_handle::Server, blob_writer::Server, web_site_publisher::Server. The idea is that the server will implement the Server traits, with structs named perhaps BlobHandleImpl, BlobWriterImpl, and WebSitePublisherImpl, and then will pass these structs to RPC system via the capnp_rpc::new_client() function. For example, to create a BlobHandle, the implementation of blob_writer::Server::done() might do:

let blob_handle = BlobHandleImpl::new(blob_bytes);
let client: blob_handle::Client = capnp_rpc::new_client(blob_handle);

The issue is that once we call capnp_rpc::new_client() we no longer have access to the underlying BlobHandleImpl object, so by the time we are in web_site_publisher::Server::set(), we cannot get to the blob’s bytes.

The solution is to use CapabilityServerSet, a feature that has existed in capnproto-c++ for a long time, and as of today has been added to capnproto-rust. If a blob_handle::Client is created via CapabilityServerSet::new_client(), instead of capnp_rpc::new_client(), then its underlying BlobHandleImpl can later be retrieved via CapabilityServerSet::get_local_server().

let mut set: CapabilityServerSet<BlobHandleImpl,blob_handle::Client> =
    CapabilityServerSet::new();
...
let blob_handle = BlobHandleImpl::new(blob_bytes);
let client: blob_handle::Client = set.new_client(blob_handle);

Then the implementation of web_site_publisher::Server could do:

if let Some(s) = set.get_local_client(&client).await {
    // s has type `&Rc<RefCell<BlobHandleImpl>>`
    ...
}

Async Packing

Cap’n Proto’s packed codec is a way to reduce message size in exchange for a minimal encoding/decoding cost.

Until recently, capnproto-rust only support the packed codec via the synchronous capnp::serialize_packed API; if you wanted to pack your data over async I/O, you were out of luck. In particular, there was no way to use the RPC system with the packed codec.

That has changed now, with the addition of the capnp_futures::serialize_packed module. It includes standalone functions serialize_packed::try_read_message() and serialize_packed::write_message(), as well as wrappers

struct PackedRead<R> where R: AsyncRead { ... }

and

struct PackedWrite<W> where W: AsyncWrite { ... }

which can be plugged into capnp_rpc::twoparty::VatNetwork::new() to enable packed RPC.

-- posted by dwrensha

capnproto-rust on github
more posts