Typed zero-copy messaging for robotics. One API, two transports:
- local SHM when both ends are on the same host — shared-memory, no copy, no serialization.
- iroh when they aren't — peer-to-peer QUIC with NAT traversal and TLS 1.3.
The routing decision happens once at subscriber() and is invisible afterwards.
Five messaging modes, one Node API. They differ by how many messages
each side sends and how the peers relate:
server sends ONE server sends MANY
┌──────────────────────┬──────────────────────┐
client sends ONE │ req/res │ que/ans │
│ 1 req → 1 res │ 1 que → 0..N ans │
├──────────────────────┼──────────────────────┤
client sends MANY │ put/ack │ pip │
│ 0..N put → 1 ack │ 0..N ↔ 0..N (bidi) │
└──────────────────────┴──────────────────────┘
pub/sub — a producer's stream fans out to every subscriber (M : N)
| Mode | client → server | server → client | flow |
|---|---|---|---|
pub/sub |
— (stream) | broadcast | 1-to-many |
req/res |
1 | 1 | 1-to-1 |
que/ans |
1 | 0..N | 1-to-many |
put/ack |
0..N | 1 | many-to-1 |
pip |
0..N | 0..N | many↔many |
Peer model — the same for every mode. A consumer addresses one
producer by (identity, topic); any number of producers and consumers
can coexist on a topic across hosts, and each consumer attaches to the
one it names. So the modes are at parity on how peers connect — they
differ only in the message flow above and in delivery: pub/sub
delivers each producer message to all attached subscribers
(fan-out), while req/res, que/ans, put/ack, and pip give each client
its own independent exchange.
Transport is orthogonal too. Same host → shared memory (zero-copy);
remote → iroh QUIC. Node picks per peer at construction time; every
shape above is identical on both.
peerbus = { git = "https://codeberg.org/robolibs/peerbus" }On Nix: nix develop. The local backend is pure Rust
(shared_memory + raw_sync) — no iceoryx2/libclang in the
dependency graph.
use peerbus::Node;
#[datapod::datapod]
struct Pose { x: f32, y: f32, yaw: f32 }
let node = Node::builder().ephemeral().no_relay().bind()?;
let mut pubr = node.publisher::<Pose>("rover/pose")?;
let mut sub = node.subscriber::<Pose>(node.endpoint_id(), "rover/pose")?;
pubr.send(&Pose { x: 1.0, y: 2.0, yaw: 0.1 })?;
if let Some(s) = sub.take()? {
println!("{:?}", s.header());
}
# Ok::<_, peerbus::Error>(())Payload types implement datapod::DataPod — typically a one-line #[datapod::datapod] annotation. Fixed-size types ride entirely in the local SHM header / iroh frame prefix; heap-bearing types (one #[dp(bytes)] field) ride the variable-length payload too.
peerbus addresses peers only by their 32-byte EndpointId — the same
id iroh dials and that seeds the shared-memory rendezvous name. IntoPeer
accepts an EndpointId or a full EndpointAddr:
let mut sub = sub_node.subscriber::<Pose>(pub_node.endpoint_id(), "rover/pose")?;For cross-host you generally want the publisher's full EndpointAddr (it
carries direct addresses / relay info, so no discovery round-trip is needed):
let pub_addr = pub_node.endpoint_addr(); // share this with peers
let mut sub = sub_node.subscriber::<Pose>(pub_addr, "rover/pose")?;Friendly names, did:key strings, name→id derivation, and grouping many
ids into one logical "robot" are intentionally not peerbus's job — they
live in a higher-level crate that resolves a name to an EndpointId and then
calls in here. peerbus itself only ever speaks 32-byte ids.
peerbus only consumes a key; it does not mint, name, or persist one:
.secret_key(key) // bind with this exact ed25519 key (its public half is the EndpointId)
.ephemeral() // bind with a fresh random key (tests, examples, short-lived nodes)
.label("rover-a") // optional, cosmetic — shown in logs only, never in routingDeriving a key from a name, loading one from a file, or persisting it is the
higher-level crate's responsibility: it produces a SecretKey and hands it to
.secret_key(...).
Inbound peers are denied by default. A node accepts an incoming connection only if the dialing peer is on its allowlist:
let node = Node::builder()
.secret_key(rover_key)
.allow_peer(planner_id)
.allow_peer(logger_id)
.bind()?;Every other connection is closed immediately after the QUIC handshake, with a WARN naming the rejected peer. This covers pub/sub and all five request modes — the check runs once, on the connection, before any stream is served.
A node that configures no allowlist rejects everyone. To accept any peer that knows the ALPN — a trusted LAN, a loopback test — opt out explicitly:
let node = Node::builder().allow_any_peer().bind()?; // WARNs at bindThe ALPN is not a secret (it is sent in the clear in the TLS ClientHello), so allow_any_peer is not access control. Only outbound dials are unfiltered; the peer you dial decides whether to accept you.
use peerbus::Node;
let server_node = Node::builder().ephemeral().bind()?;
let client_node = Node::builder().ephemeral().bind()?;
// server
let mut server = server_node.req_server::<Ping, Pong>("calc/ping")?;
while let Some((req, res)) = server.take()? {
res.respond(&handle(req.header()))?;
}
// client — address the server by its id
let mut client = client_node.req_client::<Ping, Pong>(server_node.endpoint_id(), "calc/ping")?;
let res = client.call(&Ping { /* … */ })?;
let pong: Pong = *res.header();
# Ok::<_, peerbus::Error>(())The high-level Node API chooses local SHM first and falls back to
iroh, matching pub/sub routing. Lower-level LocalReqResService and
RemoteTransport req/res APIs remain available for advanced use.
To force the iroh path even when both peers are on the same host, build the
node with skip_shm():
let node = Node::builder()
.ephemeral()
.skip_shm()
.bind()?;Such a node never creates SHM services and never probes for them. Its
publishers, subscribers, and all request-mode servers/clients use iroh only.
The same option is exposed as Node(skip_shm=True) in Python and as
PeerbusNodeConfig.skip_shm in the C ABI.
For one query that returns zero or more finite answers, use que/ans:
let mut server = server_node.ans::<RangeQue, Hit>("search/range")?;
let mut client = client_node.que_client::<RangeQue, Hit>(server_node.endpoint_id(), "search/range")?;
// server
while let Some((que, mut ans)) = server.take()? {
for value in lookup(que.header()) {
ans.send(&Hit { value })?;
}
ans.finish()?;
}
// client
let mut answers = client.send(&RangeQue { start: 10, count: 3 })?;
while let Some(hit) = answers.next()? {
println!("hit: {:?}", hit.header());
}
# Ok::<_, peerbus::Error>(())Like pub/sub and req/res, que_client(peer, topic) tries local SHM first
and falls back to iroh.
For a finite client-to-server item stream with one final acknowledgement,
use put/ack:
let mut server = server_node.ack::<LogChunk, UploadAck>("logs/upload")?;
let mut client = client_node.put_client::<LogChunk, UploadAck>("logger", "logs/upload")?;
// server
while let Some(mut puts) = server.take()? {
let mut count = 0;
while let Some(chunk) = puts.next()? {
save(chunk.payload());
count += 1;
}
puts.ack(&UploadAck { count })?;
}
// client
let mut put = client.open()?;
put.send(&chunk_a)?;
put.send(&chunk_b)?;
let ack = put.finish()?;
# Ok::<_, peerbus::Error>(())put_client(peer, topic) chooses local SHM first and then iroh.
For a bidirectional session where both sides can exchange many messages,
use pip:
let mut server = server_node.pip_server::<ClientMsg, ServerMsg>("session")?;
let mut client = client_node.pip_client::<ClientMsg, ServerMsg>("robot", "session")?;
// server
while let Some(mut pip) = server.take()? {
if let Some(msg) = pip.next()? {
pip.send(&handle(msg.header()))?;
pip.finish_send()?;
}
}
// client
let mut pip = client.open()?;
pip.send(&ClientMsg { /* … */ })?;
while let Some(msg) = pip.next()? {
handle_server_msg(msg.header());
}
# Ok::<_, peerbus::Error>(())pip_client(peer, topic) chooses local SHM first and then iroh.
node.stats(); // publisher_topics, cached_peers
pubr.stats(); // published, remote_dropped, stale_dropped, bytes_sent, send_errors
sub.stats(); // received, disconnects, stale_dropped, incomplete_dropped, bytes_received
node.peer_path_diagnostics(peer); // direct/relay, RTT, MTU, datagram capacityEnable the tracing feature for structured events on accept / connect / disconnect / handshake mismatch / broadcast lag / poisoned-mutex recovery. The loan-publish-take hot path stays uninstrumented.
- Connection drops. The subscriber loop redials with bounded backoff (100 ms → 10 s).
take()only returnsErr(Disconnected)after the foreground channel itself goes away. - Slow subscriber. The local ring's default
history_depth = 1means a subscriber that polls slower than the publisher misses samples. BumpLocalConfig::history_depth. - No subscriber attached. Remote publishes silently drop; counted under
remote_droppedonpubr.stats().
| Feature | Adds |
|---|---|
tracing |
structured events at accept / connect / disconnect / lag / errors |
python |
pyo3 CPython extension (built by maturin) |
The local SHM backend and iroh are always on; there is no feature gate for either transport. Service-discovery config files (TOML / JSON) are planned, not yet implemented — see PLAN.md.
peerbus follows the same robolibs binding layout as the sibling crates:
src/ffi.rs # C ABI implementation
include/peerbus.h # generated C header
src/python/mod.rs # Python pyo3 module
examples/c_abi/ # C ABI demos + Makefile
examples/python_binding/ # Python demo + Makefile
Generate/build bindings with the root Makefile:
make bindRun the C ABI demos:
make -C examples/c_abi runRun the Python binding demo:
python examples/python_binding/demo.pyFor language-independent datapod traffic, use the generic datapod API:
Python passes any datapod object with to_wire_message() to
DatapodPublisher.send(), and subscribers decode with the datapod type,
for example sub.take(datapod.Grid). This works for datapod containers
such as Grid and Matrix without adding peerbus APIs per type.
For high-throughput subscribers, Subscriber.take_view() and
DatapodSubscriber.take_view() return borrowed sample objects that expose
read-only memoryview payload/wire bytes without copying.
Python also exposes node.peer_path_diagnostics(endpoint_addr) for remote
path checks; the C ABI mirrors it through peerbus_node_peer_path_diagnostics
and the peerbus_peer_path_diagnostics_* accessors.
Inbound peer allowlisting is also binding-visible, and deny-by-default applies
to the bindings too: Python accepts
Node(allowed_peers=[endpoint_addr, ...], allow_any_peer=False), and C uses
PeerbusNodeConfig.allowed_peers / allowed_peers_len / allow_any_peer.
(peerbus_node_new has no ACL knob, so the node it builds refuses every
inbound connection; use peerbus_node_new_with_config to serve remote peers.)
The video examples use datapod.Grid (Encoding.Rgba8) over that generic
datapod path:
# Python publisher -> Rust Wayland subscriber
peerbus-video-pub
cargo run --release --example video_sub -- <endpoint-addr printed by Python>
# Rust publisher -> Python headless subscriber
cargo run --release --example video_pub
peerbus-video-sub <endpoint-addr printed by Rust>High-rate topics can choose transport behavior without putting datatype logic inside peerbus:
use peerbus::{TopicQos, DeliveryPolicy};
let qos = TopicQos::latest()
.with_subscriber_queue(8)
.with_max_message_bytes(64 * 1024 * 1024);
let mut pubr = node.publisher_with_qos::<DatapodMsg>("demo/video", qos)?;DeliveryPolicy::Reliable is the default. Its publisher-side remote fanout
queue is bounded by subscriber_queue; if a reliable remote subscriber falls
behind that queue, peerbus reports lag instead of silently dropping old data.
Latest lets slow remote subscribers skip stale queued samples and receive
the newest sample instead.
For local SHM subscribers, Latest/BestEffort drain immediately-available
ring samples and return only the newest one, with skipped samples counted in
sub.stats().stale_dropped.
BestEffort uses iroh QUIC datagrams on the v3 Node pub/sub path when the
peer/path supports them; otherwise it falls back to latest-over-stream.
Node subscribers advertise these QoS fields in the pub/sub v3 handshake;
lower-level v2 peers remain accepted during the transition.
When both sides use the v3 Node pub/sub path, frames larger than
chunk_bytes are split and reassembled as opaque byte chunks.
The item modes accept the byte-limit subset of TopicQos via
*_with_qos constructors (req_server_with_qos, que_client_with_qos,
ack_with_qos, pip_server_with_qos, …). Large requests, answers,
puts, and pip messages are chunked and reassembled exactly like pub/sub,
lifting the 64 MiB single-frame cap (bounded by max_inflight_bytes).
These modes are always Reliable — Latest/BestEffort and the
datagram path are pub/sub-only, since dropping an in-flight item would
break their contract. Each server and client exposes remote-path
counters via .stats() (ReqStats/QueStats/PutStats/PipStats).
Node is the recommended entry point. The pieces it composes are also public:
LocalTransport/LocalService<T>— local SHM pub/sub directly.RemoteTransport— iroh directly, for all five modes withoutNode: pub/sub, req/res (serve_requests/client), que/ans (serve_ques/que_client), put/ack (serve_puts/put_client), and pip (serve_pips/pip_client).Podpayloads; interoperates with theNodepath over iroh.AsyncPublisher/AsyncSubscriber—async fnshims over the sync core.
See examples/ for runnable demos of every mode over both
transports: req_res, que_ans, put_ack, and pip each show a
shared-memory and an iroh section, with pub/sub in node_demo (SHM)
and remote_loopback (iroh). The video_sub GUI demo is Wayland-only
(minifb).
Datapod-focused examples:
datapod_pose— minimal custom fixed-size datapod.datapod_fixed_gallery— built-in robotics/geometry datapods nested in one fixed-size user type over pub/sub.datapod_heap_payloads— heap-bearingBytesandLinestringpayloads with QoS/chunk settings.datapod_primitives_gallery— different datapods across pub/sub, req/res, que/ans, put/ack, and pip, all addressed by the server node's id.
0.3.x — pre-1.0. Wire format documented in PLAN.md, not stable between minor releases. Sharp edges in LIMITATIONS.md.