Docker realization (bridging simulation to real TCP): - NodeDriver (`crates/distribution/src/driver.rs`): bridges DistributedNode tick loop to TcpTransport with piggyback-extended wire messages - swactor-node binary (`crates/node/`): CLI node with --listen, --seed, --dashboard-port, --actors flags - Dockerfile: multi-stage build (rust:1.93-slim → debian:bookworm-slim) - Docker integration tests (`tests/docker/`): 5-node cluster with 4 scenarios (convergence, failure detection, actor resolution, rejoin) - LAN cluster scripts for cross-machine validation - TCP transport retry-on-stale-connection logic - /api/distribution REST endpoint on dashboard (feature-gated) - Piggyback fields (piggyback + from_addr) on Ping/Ack/PingReq messages Docs reorganization: - docs/runtime/ — actor-model, runtime, worker-thread, channels - docs/distribution/ — distribution, swim, kademlia, transport - docs/diagrams/ — all SVG files - docs/connectome/ — connectome analysis - docs/development_history/ — DOCKER_REALIZATION.md, SIMULATION_TESTING.md - render_docs.sh outputs to docs/diagrams/ - README links updated to new paths Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
4.9 KiB
Transport-Agnostic Messaging
Enables actors on different runtimes to communicate transparently via pluggable codecs (serialization) and transports (delivery protocol).
Feature-gated: #[cfg(feature = "transport")]. Without the flag, the
binary is identical to the baseline runtime.
cargo build --features transport
cargo test --features transport
Two Layers of Pluggability
Codec<M>— HOW bytes are encoded. gRPC/protobuf, bincode, custom, etc. No serde bounds — the codec defines what it needs fromM.Transport— WHERE bytes are sent. InMemory (testing), TCP, gRPC, etc.
See transport_routing.svg for the extended routing chain, and transport_encode_decode.svg for the encode/decode data flow.
Core Types
| Type | Role |
|---|---|
Codec<M> |
User-implemented encode/decode for a message type |
NetworkMessage |
Marker trait: adds fn type_tag() -> &'static str for wire routing |
WireEnvelope |
{ dest: ActorAddress, type_tag: String, payload: Vec<u8> } |
Transport |
fn send(WireEnvelope) -> Result<()> — pluggable delivery |
CodecRegistry |
Maps TypeId → encoder (send side) and type_tag → decoder (receive side) |
TransportRouter |
Maps ActorAddress → Arc<dyn Transport> for remote addresses |
TransportBridge |
Deserializes incoming WireEnvelope → (ActorAddress, Box<dyn Any + Send>) |
InMemoryTransport |
mpsc-backed transport for testing |
Routing Chain
Without transport, unresolved addresses fall through to InboxRegistry.
With transport enabled, a third step is inserted:
- AddressMap::lookup → local worker delivery (zero-copy, no serialize)
- InboxRegistry::contains → external inbox delivery
- TransportRouter::lookup → codec.encode + transport.send (remote)
- Fallback →
InboxRegistry::try_deliver(Err if not found)
This chain runs in Runtime::send_to, ContextInner for Runtime, and
WorkerContext::send_any — all three follow the same logic.
Type Erasure Bridge
Messages are Box<dyn Any + Send> before routing, but dyn Any can't be
serialized. The bridge:
Send: Box<dyn Any> → (*msg).type_id() → encoders[TypeId] →
downcast to M → Codec<M>::encode → (type_tag, Vec<u8>) → WireEnvelope
Receive: WireEnvelope → decoders[type_tag] → Codec<M>::decode →
Box::new(msg) as Box<dyn Any + Send> → runtime.deliver_raw(addr, msg)
TypeId (compiler-assigned, process-local) is used for encoding.
type_tag (user-defined, stable) is used on the wire for decoding.
Setup
// 1. Register codecs
let mut codecs = CodecRegistry::new();
codecs.register::<Ping, _>(MyCodec);
codecs.register::<Pong, _>(MyCodec);
// 2. Create transport + router
let (transport, rx) = InMemoryTransport::pair();
let router = TransportRouter::new();
router.add_route(remote_addr, transport);
// 3. Attach to runtime
let mut rt = Runtime::new(RuntimeConfig::default());
rt.set_codec_registry(Arc::new(codecs));
rt.set_transport_router(Arc::new(router));
// 4. Send transparently
rt.send_to(remote_addr, Ping { value: 42, reply_to: inbox_addr }).unwrap();
// 5. Receive side: bridge deserializes, deliver_raw injects
let bridge = TransportBridge::new(codecs_arc);
let (addr, msg) = bridge.receive(wire_envelope).unwrap();
rt.deliver_raw(addr, msg).unwrap();
Address Resolution
Addresses are not automatically discovered. Each runtime must be told
which remote addresses exist via router.add_route(). Since addresses are
32 random bytes, runtimes must exchange them out-of-band (e.g., over the TCP
connection itself — see examples/tcp_ping_pong.rs).
Distribution Driver
The distribution layer's NodeDriver (crates/distribution/src/driver.rs)
is the primary consumer of the TCP transport. It uses TcpTransport for
outgoing SWIM messages and TcpAcceptor for incoming, bypassing the
Codec<M> trait in favor of direct serde_json serialization (see
DOCKER_REALIZATION.md
§10.2 for rationale).
Limitations
- No automatic discovery — manual address exchange required
- Transport::send is synchronous — blocking transports stall the worker
- One route per address — no wildcard/prefix routing
- No ordering guarantees across transports (depends on transport impl)
- No back-pressure from remote — fire-and-forget delivery
- TypeId is not stable across compilations (only used process-locally; wire uses type_tag)
Where Things Live
| Concept | File |
|---|---|
| All transport types | src/transport.rs |
InboxRegistry::contains() |
src/delivery.rs |
Transport fields on TickContext |
src/delivery.rs |
Runtime::deliver_raw, setters |
src/runtime.rs |
| Transport fallback in worker | src/worker.rs |
| Integration tests | tests/transport_api.rs |
| TCP example | examples/tcp_ping_pong.rs |