Network API Direction
This document describes the current network API shape. Marionette exposes the same app-facing typed endpoint shape from simulation and production setup. Production byte endpoints can use either the local same-process adapter or the first socket-backed transport slice when listener topology is configured.
Application code should not care whether messages come from deterministic
simulator events or production IO. The composition root chooses the backing and
threads env plus node-scoped endpoints into the service.
Current Status
The app-facing network surface is mar.Endpoint(Message). An endpoint is bound
to one NodeId, so application code can only send as that node and can only
receive messages addressed to that node:
const Message = union(enum) {
write: struct { value: u64 },
ack: struct { value: u64 },
};
const sim = try world.simulate(.{ .network = .{
.nodes = 4,
.service_nodes = 3,
.path_capacity = 64,
} });
const client = try sim.endpoint(Message, 3);
const replica = try sim.endpoint(Message, 0);
try sim.control.network.setLossiness(.{ .drop_rate = .percent(20) });
try client.send(0, .{ .write = .{ .value = 42 } });
while (try replica.receive()) |envelope| {
try apply(envelope.from, envelope.message);
}
The simulator network owns a fixed topology, per-link packet queues, packet ids, seeded drops, latency, node and link state, and deterministic delivery order. Older packet-core wrappers remain confined to low-level network tests; public examples should use the composition-root endpoint API.
Two Surfaces
The network API has two separate surfaces:
- App-facing authority:
mar.Endpoint(Message). - Simulator-control authority:
control.network.
Application code should use endpoints. Test scenarios and simulation harnesses should use simulator control.
This split keeps production-shaped code portable without giving it test-only powers such as partitioning the network, stopping nodes, or changing drop rates.
App-Facing Authority
The app-facing authority is a typed sibling handle. Code that only needs a
trace should take mar.Recorder; code that needs Marionette-specific clock or
random capabilities can still take Env:
fn write(recorder: mar.Recorder, endpoint: mar.Endpoint(Message), message: Message) !void {
try recorder.record("write.start", .{});
try endpoint.send(1, message);
}
This is deliberately not a field on Env. Env is one non-generic type, while
Endpoint(Message) is message-specialized. Keeping the typed endpoint beside
Env avoids making every function that accepts Env generic.
Simulation setup wires node-scoped endpoints into production-shaped code:
fn init(world: *mar.World) !Harness {
const sim = try world.simulate(.{ .network = .{ .nodes = replica_count + 1 } });
return .{
.service = Service.init(
sim.env.recorder(),
try sim.endpoint(Message, client_node_id),
try sim.endpoints(Message, replica_count, 0),
),
.control = sim.control,
};
}
The application-shaped code depends on narrow handles such as Recorder plus
typed endpoints, not on control, World, std.net, or UnstableNetwork.
Simulator-Control Authority
The simulator-control authority is for tests, scenarios, and future schedulers:
try sim.control.network.setLossiness(.{ .drop_rate = .percent(20) });
try sim.control.network.setNode(1, false);
try sim.control.network.clog(0, 1, 100 * ns_per_ms);
try sim.control.network.partition(&left, &right);
try sim.control.network.heal();
These calls are fault orchestration. They should not be required or available in ordinary production service code.
The important constraint is that fault orchestration is separate from the
app-shaped send path. App send takes only to and message; the endpoint's
own NodeId is the sender.
Byte Endpoint
ByteEndpoint is the byte-oriented app surface for code that already owns a
wire protocol. It has the same node-scoped shape as Endpoint(Message), but it
makes byte ownership explicit:
send(to, bytes)copies borrowed bytes before returning.acquire(len)returns a pool-owned buffer.sendMessage(to, message)transfers an acquired buffer on success.receive()returns{ from, message }; the caller must release the message.
This is the intended integration point for future Zig networking/RPC libraries
that want Marionette as a test or transport backend without exposing
Endpoint(Message) to their users.
ByteTransport is the friendlier byte facade. It wraps a ByteEndpoint,
exposes send(to, bytes) and receive() with deinit, and keeps pool-message
details out of user adapter code. Adapters that need to encode directly into a
pooled buffer can use acquire(len) and then builder.send(to).
CodecTransport(Codec) is the preferred typed facade for protocol adapters.
The codec declares its own Send, Recv, recv_lifetime, encode, and
decode contract. Borrowed receive values stay valid until the received handle
is deinitialized; escaping them requires the codec to provide cloneRecv.
The built-in mar.codec.bytes and mar.codec.fixed(Send, Recv) codecs cover
pass-through bytes and raw fixed-layout values. JSON-style codecs are expected
to be user-supplied until the allocator/decode ownership contract is driven by
a real integration.
The intended shape is:
const ServerCodec = struct {
pub const Send = Response;
pub const Recv = Request;
pub const recv_lifetime: mar.CodecRecvLifetime = .owned;
pub fn encodedLen(response: Send) !usize {
return if (response == .row) 9 else 1;
}
pub fn encode(buffer: []u8, response: Send) ![]const u8 {
const len = try encodedLen(response);
buffer[0] = @intFromEnum(std.meta.activeTag(response));
if (response == .row) std.mem.writeInt(i64, buffer[1..][0..8], response.row, .little);
return buffer[0..len];
}
pub fn decode(bytes: []const u8) !Recv { ... }
};
const transport = mar.CodecTransport(ServerCodec).init(.init(byte_endpoint));
var received = (try transport.receive()) orelse return;
defer received.deinit();
try apply(received.from(), received.value());
Production Path
Production.endpoint(Message, opts) exists today and returns the same typed
endpoint shape as simulation. opts declares the local self node and peer
topology. Typed production endpoints still use the local in-process adapter.
Production.byteEndpoint(opts) uses the same local in-process adapter unless
opts.listen is set. With listen, it uses the first socket-backed loopback
transport slice behind the same ByteEndpoint/ByteTransport surface.
Reconnect, background receive, and multi-peer connection management remain
scoped under roadmap item 15. The target architecture lives in
docs/network-production.md.
The standing rules until 15h ships:
- Do not invent a large permanent socket ecosystem yet.
- Keep app-facing network requirements narrow.
- Route production through host IO at the composition root.
- Route simulation through deterministic simulator machinery.
- Keep simulator-control operations out of production service code.
Simulation Path
The simulation path is:
World.simulate(...).endpoint(Message, node)
-> app-facing typed process endpoint
-> simulator-owned unnamed bus runtime for Message
-> fixed-topology packet queues
-> World clock, World PRNG, World trace
The composition-root control plane owns the shared topology and fault state. Endpoint runtimes are created lazily per message type and shared by all node endpoints of that message type. A simulation may create many endpoints for the same message type; those endpoints share one unnamed bus.
Multi-Bus Future
The current API intentionally models one unnamed bus per message type. It does not foreclose multiple buses. If a later example needs both RPC and gossip in the same process, the likely extension is an explicit bus key:
const rpc = try sim.endpoint(Message, .{ .bus = .rpc, .node = 0 });
const gossip = try sim.endpoint(Gossip, .{ .bus = .gossip, .node = 0 });
Until that need is driven by a real example, Marionette avoids a public bus
registry. Users can still model protocol variants inside one union(enum)
message type, which is the preferred shape for VSR/Raft-style protocols.
Non-Goals For Now
Marionette is not trying to support all of production networking in the first network API. These are intentionally unresolved:
- Multiple named buses or bus registry.
- TCP versus UDP shape.
- Stream versus datagram ownership.
- Listener and connection lifetime.
- Backpressure semantics.
- TLS.
- Real DNS.
- Arbitrary
std.netcompatibility. - Cross-process simulation.
The first stable app-facing network is narrow enough to test a small multi-node service, then grow from real examples.