oxidecomputer / idolatry

An experimental IPC interface definition language for Hubris.
Mozilla Public License 2.0
17 stars 11 forks source link

Idol: interface definitions for Hubris

This is an experimental interface definition language for defining IPC interfaces between tasks in a Hubris application.

IDL format

Currently, the IDL "format" is merely the idol::syntax::Interface struct as loaded via serde from a RON file. The best docs for the format are the rustdocs on that struct and its children.

Here is a simple example.

Interface(
    name: "Spi",
    ops: {
        "exchange": (
            args: {
                "device_index": (type: "u8"),
            },
            leases: {
                "source": (type: "[u8]", read: true),
                "sink": (type: "[u8]", write: true),
            },
            reply: Result(
                ok: "()",
                err: CLike("SpiError"),
            ),
        ),
    },
)

Generating a client interface

This assumes you have an Idol file called my_interface.idol living somewhere in your source tree, which defines an IPC interface called MyInterface.

By convention in Hubris we use "API crates" to hold IPC client interfaces; for your my-interface, this would conventionally be my-interface-api.

Inside that crate, you need to add this to Cargo.toml:

[build-dependencies]
idol = {git = "https://github.com/oxidecomputer/idol/"}

And then create a build.rs file containing at least the following:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    idol::client::build_client_stub(
        "../../idl/my-interface.idol",  // or whatever path
        "client_stub.rs",
    )?;
    Ok(())
}

That will generate client code at build time. To actually compile the generated code, you have to pull it into your lib.rs by adding:

include!(concat!(env!("OUT_DIR"), "/client_stub.rs"));

This will generate:

Generating a server

This assumes you have an Idol file called my_interface.idol living somewhere in your source tree, which defines an IPC interface called MyInterface.

By convention in Hubris, the server for my-interface would be in a crate called my-interface-server (unless there are multiple implementations).

Inside that crate, you need to add this to Cargo.toml:

[dependencies]
idol-runtime = {git = "https://github.com/oxidecomputer/idol/"}

[build-dependencies]
idol = {git = "https://github.com/oxidecomputer/idol/"}

And then create a build.rs file containing at least the following:

fn main() -> Result<(), Box<dyn std::error::Error>> {
    idol::server::build_server_support(
        "../../idl/my-interface.idol",  // or whatever path
        "server_stub.rs",
        idol::server::ServerStyle::InOrder,
    )?;
    Ok(())
}

(More on that ServerStyle in a moment.)

That will generate server code at build time. To actually compile the generated code, you have to pull it into your main.rs by adding:

include!(concat!(env!("OUT_DIR"), "/server_stub.rs"));

This will generate a trait describing the required implementation of the server. The trait is called InOrderMyInterfaceImpl in this case, and contains a function for each IPC operation. To finish writing a server, you must impl this trait for the type of your choice (generally, a struct holding your server's state).

Finally, the generated code has provided plumbing for you to use this trait with the generic idol_runtime::dispatch function. Your server's main loop should resemble, at minimum:

let mut incoming = [0u8; INCOMING_SIZE];
let mut server = MyServerImpl;
loop {
    idol_runtime::dispatch(&mut incoming, &mut server);
}

Variations and corner cases

Servers that use notifications

To handle notifications,

Servers that process multiple messages concurrently

The default InOrder server style must reply to each message before accepting the next. Some servers are more complex than this and have multiple messages in flight. For such servers, switch the style to Pipelined.

In Pipelined style, the generated trait changes: instead of each function returning the IPC's return type, they all return (). Its name also changes, from InOrder(YourService)Impl to Pipelined(YourService)Impl.

This causes dispatch and friends to not automatically send replies, except in egregious cases (operation unknown, arguments failed to unmarshal). Instead, your impl of the server trait is responsible for replying to clients at the appropriate type, using userlib::sys_reply or a wrapper.

When implementing a pipelined server, it's easy to accidentally forget to reply to clients -- be careful!

Using closed RECV

Generated server traits include two functions supporting closed RECV, which are stubbed out by default. Implement them to be able to switch between open and closed at will:

    fn recv_source(&self) -> Option<userlib::TaskId>;
    fn closed_recv_fail(&mut self);

recv_source names the task to listen to, or None for all tasks. closed_recv_fail will be called by idol_runtime if you name a specific task but that task has died.

Enums as arguments

By default, Idol uses zerocopy to marshal and unmarshal argument and return types. This requires that, on the receiving side, any pattern of bits must be a valid member of the type. This isn't true for bool or most enums.

You can request that Idol instead use num_traits::FromPrimitive to unmarshal values. Instead of

"cs_state": (type: "CsState"),

you want:

"cs_state": (
    type: "CsState",
    recv: FromPrimitive("u8"),
,

The value taken by recv is an enum so we can add more methods in the future.

Booleans as arguments

As mentioned above, booleans are not normally valid for use with zerocopy. They also don't implement From or TryFrom, so the alternate recv strategies above don't work.

Since bool is a common type, they are special-cased in Idol to pack into a single u8. The value is encoded with false => 0, true => 1, and decoded with 0 => false, * => true. Tools which call into Idol functions without using the generated client API (e.g. Humility) must also implement this special-case behavior.