UART IPC Transport PoC (consortium-ipc-transport-uart*)
Context
Consortium’s IPC today has one transport: shared memory (consortium-ipc-transport-memory). The docs (IPC introduction) already classify UART as a Wired transport with a Type-B doorbell (notification implicit in transport activity), and Doorbell’s docs reserve the Chan meaning “Serial: logical channel byte in frame header”. This plan adds UART as the second transport candidate, as a proof of concept:
- embedded side speaks
embedded-io-async, - _nix side speaks
/dev/tty_, - split across
consortium-ipc-transport-uart(unified entry + frame protocol),consortium-ipc-transport-uart-embedded,consortium-ipc-transport-uart-unix.
Key decision (user-approved): refactor SendTransport/RecvTransport in consortium-ipc from GAT named futures to RPITIT (-> impl Future<...> + MaybeSend, stable since 1.75). Rationale: embedded_io_async::Read/Write futures are unnameable, so they cannot satisfy type SendFut<'a> on stable without boxing (alloc on MCU) or nightly TAIT. Grep confirmed nothing outside the trait/impl files names SendFut/RecvFut; Doorbell (and its WaitFut GAT) stays unchanged.
PoC scope (user-approved): frame protocol + core transport + full unix backend with host tests; embedded crate created as a thin/stub layer with a thumbv8m compile check only. Config schema (Consortium.toml), builder env vars, multi-channel demux, DMA, and the IRQ-fed RX ring are explicitly out of scope for this step.
Step 1 — RPITIT refactor in consortium-ipc
crates/consortium-ipc/src/transport.rs:
#![allow(unused)]
fn main() {
pub trait SendTransport: TransportError {
fn send(&mut self, data: &[u8])
-> impl Future<Output = Result<(), Self::Error>> + MaybeSend;
fn max_send_size(&self) -> usize;
}
pub trait RecvTransport: TransportError {
fn recv(&mut self, buf: &mut [u8])
-> impl Future<Output = Result<usize, Self::Error>> + MaybeSend;
fn max_recv_size(&self) -> usize;
}
}
(Edition 2024 RPITIT captures all in-scope lifetimes automatically, so the old explicit 'a plumbing disappears.)
Update call sites / impls:
crates/consortium-ipc/src/channel.rs—send/recvbodies already just.await; only theMockTransporttest impls change (droptype SendFut/RecvFut, return the sameready(..)values behind-> impl Futureor convert toasync fn).crates/consortium-ipc/src/transceiver.rs— delegates throughChannel; adjust bounds only if it repeats the GAT names.crates/consortium-ipc-transport-memory/src/transport.rs— the sixSendTransport/RecvTransportimpl blocks (combined + Tx/Rx halves) drop thetype SendFut/RecvFutassociated types and return the existing hand-rolledSendFut/RecvFutstructs from-> impl Future + MaybeSendmethods. The future structs themselves are untouched.- Transport-memory tests (
tests/async_atomic_waker/,tests/async_embassy/, etc.) implementDoorbell, not the transport traits — expected to pass unchanged; fix any stragglers found bycargo test.
Step 2 — consortium-ipc-transport-uart (unified entry, owns the wire protocol)
New crate under crates/ (auto-picked-up by the workspace crates/* glob). no_std unless std; feature shape mirrors transport-memory:
[features]
default = ["std"]
alloc = ["consortium-ipc/alloc"]
defmt = ["consortium-ipc/defmt", "consortium-log/defmt", "dep:defmt"]
embedded = ["dep:consortium-ipc-transport-uart-embedded"]
unix = ["dep:consortium-ipc-transport-uart-unix", "std"]
std = ["alloc", "consortium-log/tracing"]
Deps: consortium-ipc (default-features = false), consortium-log, embedded-io-async = 0.7 (no_std), cobs (default-features = false), crc32fast (already in workspace, default-features = false), thiserror.
Wire frame (src/frame.rs)
COBS-delimited for self-resynchronization after line noise:
COBS( chan: u8 | payload[0..=MTU] | crc32: u32 LE ) 0x00
chanis the logical channel byte per theDoorbelldoc contract; PoC accepts only the constructedChanand drops mismatches (single channel per port, like the GPIO doorbell is single-channel).- CRC32 via
crc32fastoverchan | payload. pub const fn max_frame_len(mtu: usize) -> usizesizing helper (COBS overhead = ⌈len/254⌉ + 1, plus delimiter) so callers/static_buf!can size scratch buffers.- Pure functions
encode_frame(chan, payload, out) -> usizeand an incrementalFrameDeframerstate machine (push_byte/push_slice→Option<frame>), fully unit-testable without IO.
Transport (src/transport.rs)
No Side, no Doorbell — byte arrival is the Type-B doorbell:
#![allow(unused)]
fn main() {
pub struct UartTransportTx<W: embedded_io_async::Write> {
chan: Chan, io: W, scratch: &'static mut [u8], mtu: usize,
}
pub struct UartTransportRx<R: embedded_io_async::Read> {
chan: Chan, io: R, deframer: FrameDeframer, acc: &'static mut [u8], mtu: usize,
}
pub struct UartTransport<R: Read, W: Write> { tx: UartTransportTx<W>, rx: UartTransportRx<R> }
}
- Caller-provided
&'static mut [u8]scratch/accumulation buffers, sized viamax_frame_len(mtu)— same no-alloc discipline asChannel(consortium-runtime-mcu::static_buf!works for these). impl SendTransport:async fn-style body —encode_frameinto scratch,io.write_all(..).await,io.flush().await.max_send_size() == mtu.impl RecvTransport: loopio.read(chunk).await, feed the deframer; on complete frame verify CRC + chan, copy payload into the caller’s buffer, return length. Corrupt/oversize/foreign-chan frames are dropped (counted viaconsortium-logwarn) and the loop continues —recvonly resolves with a valid frame.- Constructed from separate R/W halves so HAL split UART halves and tokio split halves both fit;
UartTransport::split()returns the Tx/Rx structs for simultaneousChannel<Tx,..>/Channel<Rx,..>use, mirroringSharedMemoryTransport::split(). - Error enum via
thiserror(Io(E),FrameTooLarge,BufferTooSmall),defmt::Formatbehind the feature, matching doorbell-crate error style.
Step 3 — consortium-ipc-transport-uart-unix
Thin Linux/macOS std crate:
tokio-serial(v5, MIT — meets dependency policy) opens/dev/tty*with raw mode + baud;embedded-io-adapters(tokio-1feature) wraps the tokio halves asembedded_io_async::Read/Write.- Public surface:
open(path, baud) -> Result<(UartRx, UartTx)>where the returned types areFromTokio<ReadHalf<SerialStream>>newtypes/aliases feeding straight intoUartTransport::new. SerialStream::pair()(pty pair) is exposed for tests behind#[cfg(unix)].
Step 4 — consortium-ipc-transport-uart-embedded (stub for PoC)
Because the core is already generic over embedded-io-async (which Embassy/modern HAL UARTs implement directly), this crate starts minimal:
no_stdcrate that re-exportsembedded_io_asyncand the core transport types, plus doc comments describing the intended pattern (app owns#[interrupt], per repo convention).- Planned-but-deferred (documented in the crate docs, not built now): IRQ-fed RX ring implementing
embedded_io_async::Readfor PAC-level UARTs without an async HAL. - Serves as the compile-check anchor for
thumbv8m.main-none-eabihf.
Step 5 — Tests
In crates/consortium-ipc-transport-uart/tests/:
frame.rs— encode/decode roundtrip, byte-at-a-time feeding, resync after leading garbage, CRC corruption dropped, oversize frame dropped,max_frame_lenbounds.loopback.rs(std) — in-memory duplex fake implementingembedded_io_async::Read/Write(Tokio mpsc-backed, lives intests/common/); transport roundtrip, split halves, fragmented delivery, and a codec-backedChannel<Tx/Rx>postcard roundtrip mirroring the transport-memory channel tests.
In crates/consortium-ipc-transport-uart-unix/tests/:
pty_e2e.rs—SerialStream::pair()pty loopback: typedTransceiver-style exchange both directions over a real tty fd (runs on macOS and Linux).
Verification
cargo test -p consortium-ipc
cargo test -p consortium-ipc-transport-memory # refactor regression
cargo test -p consortium-ipc-transport-memory --no-default-features --test async_atomic_waker_test
cargo test -p consortium-ipc-transport-uart
cargo test -p consortium-ipc-transport-uart-unix
just test ipc host
cargo check -p consortium-ipc-transport-uart --no-default-features --target thumbv8m.main-none-eabihf
cargo check -p consortium-ipc-transport-uart-embedded --target thumbv8m.main-none-eabihf
just lint && just fix
End-to-end demo (the PoC deliverable): a small examples-style test or doc snippet driving two UartTransports over the pty pair exchanging postcard-encoded typed messages.
Out of scope (follow-ons, noted for later)
Consortium.tomlschema (transport = "uart",config.uart = { device, baud }) →consortium-cfg-commontypes first, then validation/lowering/builder env vars (CONSORTIUM_IPC_UART_*).- Multi-channel demux over one port (needs a dispatcher task); PoC is one channel per port.
- Embedded IRQ ring + DMA TX; AGENTS.md / ipc.md doc updates once the API settles.