Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions util/service/BUILD.bazel
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
# Licensed under the Apache-2.0 license
# SPDX-License-Identifier: Apache-2.0

load("@rules_rust//rust:defs.bzl", "rust_library")
load("@rules_rust//rust:defs.bzl", "rust_library", "rust_test")

rust_library(
name = "service",
srcs = ["lib.rs"],
srcs = [
"lib.rs",
"loopback.rs",
],
crate_name = "util_service",
edition = "2024",
visibility = ["//visibility:public"],
)

rust_test(
name = "loopback_test",
crate = ":service",
)
110 changes: 15 additions & 95 deletions util/service/README.md
Original file line number Diff line number Diff line change
@@ -1,104 +1,24 @@
# util_service

The seams every IPC service is built from. `#![no_std]`, host-buildable, no
kernel dependencies.

A service splits into three parts: wire marshalling in the service's own `api`
crate, a server that turns one request frame into one response frame, and a
transport that carries frames between them. This crate holds the traits that
seam sits on, so a service defines its wire format and nothing else.
Transport and dispatch traits for IPC services. `#![no_std]`, no kernel
dependencies.

## Traits

### [`Transport`](lib.rs)

One round-trip, caller waits for the response. For a thread dedicated to one
service, or an in-process path before IPC exists.

```rust
pub trait Transport {
fn transact(&mut self, req: &[u8], resp: &mut [u8]) -> Result<usize, TransportError>;
}
```

### [`AsyncTransport`](lib.rs)

The same round-trip split so the caller never blocks. For a caller running in
an event loop. `poll` returns `Ok(None)` while the response is outstanding.

```rust
pub trait AsyncTransport {
fn start(&mut self, req: &[u8]) -> Result<(), TransportError>;
fn poll(&mut self, resp: &mut [u8]) -> Result<Option<usize>, TransportError>;
fn cancel(&mut self) -> Result<(), TransportError>;
}
```

`poll` never waits. The caller parks its event loop until the response to this
round-trip arrives (for a kernel transport, `Signals::READABLE` on its channel
handle, registered with a WaitGroup) and polls once when it does. The handle
comes from the concrete transport at wiring time, not through the trait: a
loopback has no handle to give.

A server nudging the client out of band, with no round-trip outstanding, is a
separate mechanism and not part of this seam. The nudge carries no payload: the
client answers it by starting a round-trip and asking what happened.

Neither transport trait is the fallback for the other: a blocking transport has
no way to poll, and an event loop cannot wait. A type implements whichever it
can serve, or both.

### Using a transport from an event loop

Two pieces of code, and only one of them is generic.

Wiring runs once at startup and knows the concrete transport, because what to
park on is a property of that transport and not of the seam. A kernel
transport hands out its channel handle; a loopback has none to give, and needs
none, because its response is ready the moment the request is.

```rust
// Wiring: concrete type, once at startup.
let mut transport = AsyncChannelTransport::new(handle, send_buf, recv_buf);
wait_group.add(transport.as_raw(), Signals::READABLE)?;
```

The client is generic over `AsyncTransport` and never asks what it is talking
to. It encodes a request, starts the round-trip, and returns to the loop.

```rust
// Client: generic over T: AsyncTransport.
let len = encode_request(&mut req, op)?;
transport.start(&req[..len])?;

// On each wake, poll each round-trip still in flight. Ok(None) means this
// one's response has not arrived; the wake was for something else.
match transport.poll(&mut resp)? {
Some(len) => handle(decode_response(&resp[..len])?),
None => {}
}
```

That split is why registration is not on the trait. Everything a service
writes once and reuses is in the second block; only the composition root needs
the first, and it is concrete by nature.

A server nudge (no round-trip outstanding) wakes the same loop through its own
signal. The client answers it by starting a round-trip and asking what changed.
`Transport` blocks until the response arrives. `AsyncTransport` splits the
round-trip so the caller never blocks: `start`, poll `Ok(None)` until
`Ok(Some(len))`. `Dispatch` is the server end, one request frame in, one
response frame out.

### [`Dispatch`](lib.rs)
Wake-up registration (channel handle, WaitGroup) needs the concrete
transport, so it happens at wiring time, not through the trait.

The server end. One request frame in, one response frame out, no state between
calls beyond what the service itself owns. The same impl backs the production
channel and the in-process loopback, so host tests exercise the real server.
## Loopback

```rust
pub trait Dispatch {
fn dispatch(&mut self, request: &[u8], response: &mut [u8]) -> Result<usize, DispatchError>;
}
```
`Loopback<D, N>` wraps a `Dispatch` and answers in-process. Both transport
traits work because the response is ready the moment the request is. `N`
sizes the async response buffer. The blocking path writes into the
caller's buffer directly.

A service encodes its own errors into the response frame, so a failed operation
is still a frame and still `Ok`. `DispatchError::ResponseTooSmall` is the one
case with nothing to send back: the response buffer cannot hold even an error
frame.
Because `poll` never returns `Ok(None)`, a loopback cannot test not-ready
handling. Use a real channel or a stub for that.
102 changes: 31 additions & 71 deletions util/service/lib.rs
Original file line number Diff line number Diff line change
@@ -1,42 +1,29 @@
// Licensed under the Apache-2.0 license
// SPDX-License-Identifier: Apache-2.0

//! The seams every IPC service is built from.
//! Transport and dispatch traits for IPC services.
//!
//! A service splits into wire marshalling (host-buildable, in the service's
//! own `api` crate), a server that turns one request frame into one response
//! frame, and a transport that carries frames between the two. This crate
//! holds the three traits that seam sits on, so a service defines its wire
//! format and nothing else.
//!
//! A transport comes in two shapes and a type implements whichever it can
//! serve. `Transport` blocks until the response arrives, which is what a
//! dedicated server thread or an early-boot in-process path wants.
//! `AsyncTransport` starts a round-trip and returns, so a caller running in
//! an event loop never blocks. Neither is the fallback for the other: a
//! blocking transport has no way to poll, and an event loop cannot wait.
//!
//! `Dispatch` is the server end. One request frame in, one response frame
//! out, no state between calls. The same impl backs the production channel
//! and the in-process loopback, so host tests exercise the real server.
//! Three traits: `Transport` (blocking), `AsyncTransport` (split-phase),
//! and `Dispatch` (server). A service defines its wire format in its own
//! crate and plugs into these.

#![no_std]

/// Why a transport round-trip failed. Small and service-neutral;
/// service-level status travels inside the response payload, not here.
mod loopback;

pub use loopback::Loopback;

/// Why a transport round-trip failed.
///
/// `WrongState` belongs to `AsyncTransport`: a blocking `transact` has no
/// state between calls to get wrong. The other two apply to both.
/// Service-level errors travel inside the response payload, not here.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TransportError {
/// The underlying channel, syscall, or loopback call failed.
/// The channel, syscall, or loopback call failed.
Failed,
/// `start` was called while a round-trip was still in flight, or
/// `poll`/`cancel` was called with nothing in flight.
/// `start` while in flight, or `poll`/`cancel` with nothing in flight.
WrongState,
/// The request does not fit the transport's request buffer, or the
/// response does not fit the caller's.
/// Request or response does not fit its buffer.
TooLarge,
}

Expand All @@ -52,63 +39,41 @@ impl core::fmt::Display for TransportError {

impl core::error::Error for TransportError {}

/// Bytes in, bytes out, one round-trip, caller waits for the response.
///
/// `transact` writes the response into `resp` and returns its length. The
/// request is one fully serialized frame and so is the response. No
/// fragmentation, no state between calls.
///
/// Implement this when the caller can afford to wait: a thread dedicated to
/// one service, or an in-process path before IPC exists. A caller inside an
/// event loop wants `AsyncTransport` instead.
/// One round-trip, caller waits for the response.
pub trait Transport {
fn transact(&mut self, req: &[u8], resp: &mut [u8]) -> Result<usize, TransportError>;
}

/// Bytes in, bytes out, one round-trip at a time, split so the caller never
/// blocks.
/// One round-trip at a time, split so the caller never blocks.
///
/// `start` takes one fully serialized request and returns immediately; the
/// transport copies what it needs, so `req` is free afterwards. `poll`
/// returns `Ok(None)` while the response is still outstanding and
/// `Ok(Some(len))` once `resp[..len]` holds one fully serialized reply.
/// `len` is never 0: a server that cannot produce a frame at all is a
/// failed round-trip, not an empty reply.
/// `start` copies the request and returns immediately. `poll` returns
/// `Ok(None)` while the response is outstanding, `Ok(Some(len))` when
/// `resp[..len]` holds the reply. `poll` never waits.
///
/// `poll` never waits: with no response ready it returns `Ok(None)` and
/// returns. The caller is expected to be signal-driven rather than
/// spinning, parking its event loop until the response to this round-trip
/// arrives and polling once when it does. Registering for that needs the
/// concrete transport (a kernel one hands out its channel handle, a
/// loopback has none), so it happens at wiring time, not through this
/// trait.
/// One round-trip at a time: `start` while in flight or `poll`/`cancel`
/// with nothing in flight is `WrongState`. Any error from `poll` ends
/// the round-trip.
///
/// A server that raises a signal to say it has news, with no round-trip
/// outstanding, is not this trait's concern. The caller learns what the
/// news is by starting a round-trip and asking.
///
/// A transport carries one round-trip at a time: `start` while another is in
/// flight, or `poll`/`cancel` with none, is `WrongState`. Any error from
/// `poll` ends the round-trip, so the next call is `start`.
/// Registering for wake-up signals needs the concrete transport (a
/// kernel channel has a handle, a loopback does not), so that happens
/// at wiring time, not through this trait.
pub trait AsyncTransport {
fn start(&mut self, req: &[u8]) -> Result<(), TransportError>;

fn poll(&mut self, resp: &mut [u8]) -> Result<Option<usize>, TransportError>;

/// Abandon the round-trip in flight. The response, if one arrives, is
/// discarded.
/// Abandon the in-flight round-trip.
fn cancel(&mut self) -> Result<(), TransportError>;
}

/// Why a dispatch produced no response frame at all.
///
/// A service encodes its own errors into the response frame, so a failed
/// operation is still a frame and still `Ok`. This is the one case where
/// there is nothing to send back.
/// Service errors go in the response frame (still `Ok`). This covers
/// the case where even an error frame does not fit.
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DispatchError {
/// `response` is too small to hold even an error frame.
/// Response buffer too small for any frame.
ResponseTooSmall,
}

Expand All @@ -122,15 +87,10 @@ impl core::fmt::Display for DispatchError {

impl core::error::Error for DispatchError {}

/// The server end: one request frame in, one response frame out.
///
/// Returns the number of bytes written to `response`, always at least one.
/// A loopback transport writes into the caller's buffer, so it reports a
/// `DispatchError` as `TransportError::TooLarge`.
/// Server end: one request frame in, one response frame out.
///
/// Implementations hold no state between calls beyond whatever the service
/// itself owns, so the same impl serves the production channel and the
/// in-process loopback.
/// Returns bytes written to `response`. No state between calls beyond
/// what the service itself owns.
pub trait Dispatch {
fn dispatch(&mut self, request: &[u8], response: &mut [u8]) -> Result<usize, DispatchError>;
}
Loading