library: the envelope — one packet shape for every protocol
A protocol is now defined through envelope.Define rather than beside it.
Every packet begins with the same 16-byte prefix: a Header of {operation,
target} for a request or event, a Status for a reply. The header is folded
into each protocol's own layout, never stacked on top of it — Define walks
a spec's types and refuses one that carries a Header or Status field, so
the rule cannot be missed by reading. Operations number from 16, leaving
describe, enumerate, subscribe and unsubscribe reserved and answerable the
same way everywhere; describe the envelope answers itself, and an unknown
verb is -ENOSYS without a provider writing a line.
The size floor becomes something protocols can compile against: 256 bytes
for a call, 64 for a push, matching the kernel's own limits, checked at the
Define call with the prefix counted in. A protocol that outgrows either
fails the build with the reason and the remedy — shrink, or move the bulk
to shared memory, because packets never fragment. Events carry the header
too and number in their own space, so appending an operation can never
renumber a shipped event.
Beside it, the client half of the model: a Channel is what opening a
/protocol name yields, wrapping the endpoint capability so a program holds
a conversation rather than a handle. Nothing speaks through either yet —
the vfs protocol gains NodeKind.protocol and the convention that an open
reply may carry a capability, and that is the whole runtime change.
Suite 107/107, unchanged: P1 converts nothing.
This commit is contained in:
parent
ac01f627d1
commit
1ff0991452
|
|
@ -161,15 +161,26 @@ Aligned to the node-kind table in the file-system hierarchy
|
|||
| 4 | symbolic link |
|
||||
| 5 | fifo |
|
||||
| 6 | socket |
|
||||
| 7 | protocol |
|
||||
|
||||
Clients should map unknown values to *regular* rather than reject — the
|
||||
table can grow. Kind 6 (`socket`) keeps its wire value but is retired from
|
||||
the design — a named rendezvous point is exactly what a `protocol` node is,
|
||||
planned as value 7 with the protocol namespace
|
||||
landed as value 7 with the protocol namespace
|
||||
(docs/os-development/protocol-namespace.md). `character_device` (stream
|
||||
semantics — the tty/console shape) and `block_device` (raw sector-addressed
|
||||
volumes, reserved) remain part of the design.
|
||||
|
||||
## An open reply may carry a capability
|
||||
|
||||
`open` rides `ipc_call`, whose reply direction can hand back an endpoint
|
||||
capability alongside the `Reply` header. A file backend never uses it — FAT
|
||||
answers with a node id and nothing else — but a **synthetic** backend does:
|
||||
opening a `protocol` node returns the provider's endpoint, and possession of
|
||||
that endpoint *is* the channel. The convention is per-backend, not
|
||||
per-operation, so a client that opens an ordinary file simply receives no
|
||||
capability, exactly as before.
|
||||
|
||||
## Lifetimes and trust
|
||||
|
||||
Open-node ids live in the backend. A client that dies without closing leaks
|
||||
|
|
@ -188,8 +199,8 @@ What a non-Zig implementation may rely on, and what it must not:
|
|||
- Operation values, flag bits, `NodeKind` values, and struct layouts are
|
||||
**append-only and frozen once shipped**. The unit test in
|
||||
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the `DirectoryEntry`
|
||||
size, `NodeKind` 0–1, `Operation` values 0, 4 and 5); this page is the
|
||||
full record of the frozen values.
|
||||
size, `NodeKind` 0–1 and 6–7, `Operation` values 0, 4 and 5); this page is
|
||||
the full record of the frozen values.
|
||||
- The 256-byte message ceiling is a property of the current IPC transport,
|
||||
not a promise; clients should read `maximum_payload`-shaped limits from the
|
||||
reply lengths they actually get (loop-until-done), not hard-code 224.
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ the logging/USB-lifecycle track).
|
|||
- [x] **PM** — path-migration flag-day (`/etc`→`/system/configuration`, `/var/log`→`/system/logs`, `/mnt/usb`→`/volumes/usb`; vfs carve-out for the two writable `/system` subtrees, FAT's `/var` mount split in two; suite 106/106)
|
||||
- [x] **H1** — the `user-memory` module; nine stragglers converted; leaf U/S+W checks (plus physmap-coverage confirmation, so an `mmio_map`'d buffer cannot fault ring 0 — this also closes the same hazard on the IPC path; `fs_resolve`'s out-capacity bound made overflow-safe; suite 107/107)
|
||||
- [x] **merge** group 1 → main, push (f3bc23c, 2026-07-31)
|
||||
- [ ] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel`
|
||||
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
|
||||
- [ ] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups)
|
||||
- [ ] **P3** — open grants: `protocol.csv` enforcement, denial test
|
||||
- [ ] **merge** group 2 → main, push
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ pub fn build(b: *std.Build) void {
|
|||
.{ .name = "time", .module = time },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("file-system", .{
|
||||
const file_system = b.addModule("file-system", .{
|
||||
.root_source_file = b.path("file-system.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
|
|
@ -61,6 +61,19 @@ pub fn build(b: *std.Build) void {
|
|||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
},
|
||||
});
|
||||
// The channel is the L1 concept made concrete (docs/os-development/communication.md):
|
||||
// it needs the namespace (file-system, to resolve a /protocol name) and the
|
||||
// transport (ipc) both, which is why it lives here rather than in a protocol
|
||||
// module — those import nothing.
|
||||
_ = b.addModule("channel", .{
|
||||
.root_source_file = b.path("channel.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "file-system", .module = file_system },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("memory", .{
|
||||
.root_source_file = b.path("memory/memory.zig"),
|
||||
.imports = &.{
|
||||
|
|
@ -101,4 +114,21 @@ pub fn build(b: *std.Build) void {
|
|||
});
|
||||
test_step.dependOn(&b.addRunArtifact(kernel_tests).step);
|
||||
}
|
||||
|
||||
// channel needs its whole import set to compile at all; only its framing is
|
||||
// host-runnable (the syscall seams are x86_64-only, and unreferenced from
|
||||
// the tests), so that is what it tests.
|
||||
const channel_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("channel.zig"),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
.imports = &.{
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "file-system", .module = file_system },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(channel_tests).step);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,220 @@
|
|||
//! `Channel` — layer L1 of the communication stack
|
||||
//! (docs/os-development/communication.md) made concrete. A program holds a
|
||||
//! channel that speaks a protocol; it does not hold a raw handle and marshal
|
||||
//! bytes at one. The channel is the answer to "who am I talking to", decided
|
||||
//! once at establishment, so nothing after that ever routes a party again:
|
||||
//! every packet's `target` addresses an *object* within the peer already chosen.
|
||||
//!
|
||||
//! **Possession of the Channel is the connection.** There is no connect step, no
|
||||
//! session id, no reconnect handshake — the endpoint capability inside is the
|
||||
//! whole of the relationship, and it cannot be forged, only handed over. Which
|
||||
//! also means a channel is a resource: `close` it, or it occupies a handle-table
|
||||
//! slot for the life of the process.
|
||||
//!
|
||||
//! **A dead provider surfaces as `-EPEER`, and the recovery is to re-open.**
|
||||
//! When the process on the other end exits, the kernel fails calls on its
|
||||
//! endpoint rather than blocking forever; `call` returns null. The client does
|
||||
//! not repair the channel — it discards it and opens the name again, which
|
||||
//! reaches whatever instance the registry now points at. The restart story
|
||||
//! falls out of the naming layer for free; no protocol needs a reconnect verb.
|
||||
//!
|
||||
//! `open` resolves a `/protocol/<name>` path through the kernel VFS router and
|
||||
//! takes the provider's endpoint from the open reply's capability. **No registry
|
||||
//! is mounted at `/protocol` yet** — init grows one in P2
|
||||
//! (docs/os-development/protocol-namespace.md) — so `open` cannot succeed today;
|
||||
//! it is here so the client half of the conversation is written once, against
|
||||
//! the shape the registry will answer with.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const file_system = @import("file-system");
|
||||
const vfs_protocol = @import("vfs-protocol");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// Longest `/protocol/...` path this client marshals. The registry's names are
|
||||
/// short by construction (a contract leaf, not a file path), and the buffer is
|
||||
/// on the stack of whoever opens.
|
||||
pub const path_maximum: usize = 224;
|
||||
|
||||
/// What a `call` came back with: the provider's status, the reply payload (the
|
||||
/// bytes after the `Status`, in the caller's own buffer), and any capability the
|
||||
/// reply carried.
|
||||
pub const Response = struct {
|
||||
status: envelope.Status,
|
||||
payload: []u8,
|
||||
capability: ?ipc.Handle,
|
||||
|
||||
/// Whether the provider answered success. A negative status is its refusal
|
||||
/// (`-ENOSYS` for a verb it does not implement, and so on).
|
||||
pub fn succeeded(self: Response) bool {
|
||||
return self.status.status == 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// An open conversation with one provider, speaking one protocol.
|
||||
pub const Channel = struct {
|
||||
/// The provider's endpoint. Sending into it is the only thing this handle
|
||||
/// can do — an endpoint is a mailbox owned by its creator, and that
|
||||
/// direction never reverses.
|
||||
endpoint: ipc.Handle,
|
||||
|
||||
/// Adopt an endpoint that arrived some other way — a capability delivered
|
||||
/// in a reply, or one a supervisor wired in at spawn time (P5). The channel
|
||||
/// takes ownership of the handle.
|
||||
pub fn adopt(endpoint: ipc.Handle) Channel {
|
||||
return .{ .endpoint = endpoint };
|
||||
}
|
||||
|
||||
/// Establish a channel by name: resolve `/protocol/<name>` to the registry
|
||||
/// backend, `open` the contract there, and take the provider's endpoint from
|
||||
/// the reply's capability. Null if the path does not resolve, the registry
|
||||
/// refuses (an ungranted name is refused *as* not-found), or the reply
|
||||
/// carries no capability.
|
||||
///
|
||||
/// The path is spoken exactly once, here. Everything afterwards is integers
|
||||
/// in the packet header.
|
||||
pub fn open(path: []const u8) ?Channel {
|
||||
var relative: [path_maximum]u8 = undefined;
|
||||
const route = file_system.fsResolve(path, 0, &relative) orelse return null;
|
||||
// A protocol name must land on a mounted backend. The kernel-served
|
||||
// `/system` tree answers with a node token and knows nothing of
|
||||
// channels, so that route is simply the wrong path.
|
||||
const backend = switch (route) {
|
||||
.kernel => return null,
|
||||
.backend => |b| b,
|
||||
};
|
||||
// The registry handle is deduplicated by the kernel across resolves and
|
||||
// shared with every other user of that mount, so it is not ours to
|
||||
// close — only the provider endpoint below belongs to this channel.
|
||||
const name = relative[0..backend.path_len];
|
||||
|
||||
var request: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const header = vfs_protocol.Request{
|
||||
.operation = .open,
|
||||
.node = 0,
|
||||
.offset = 0,
|
||||
.len = @intCast(name.len),
|
||||
.flags = 0,
|
||||
};
|
||||
if (vfs_protocol.request_size + name.len > request.len) return null;
|
||||
@memcpy(request[0..vfs_protocol.request_size], std.mem.asBytes(&header));
|
||||
@memcpy(request[vfs_protocol.request_size..][0..name.len], name);
|
||||
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(backend.handle, request[0 .. vfs_protocol.request_size + name.len], &reply, null) catch return null;
|
||||
if (answer.len < vfs_protocol.reply_size) return null;
|
||||
const decoded = std.mem.bytesToValue(vfs_protocol.Reply, reply[0..vfs_protocol.reply_size]);
|
||||
if (decoded.status != 0) return null;
|
||||
// The capability *is* the channel — an open that succeeds without one
|
||||
// was answered by a file backend, which does not speak protocols.
|
||||
const provider = answer.cap orelse return null;
|
||||
return .{ .endpoint = provider };
|
||||
}
|
||||
|
||||
/// Send one request packet and block for the reply: `[Header][request]` out,
|
||||
/// `[Status][reply]` back. `request` is the bytes *after* the header — the
|
||||
/// protocol's fixed part plus any tail — because the header is this call's
|
||||
/// to lay down. The reply's payload lands in `into`.
|
||||
///
|
||||
/// Null means the transport failed, which today means one of: a dead
|
||||
/// provider (`-EPEER` — discard this channel and `open` the name again), an
|
||||
/// oversized packet, or a bad handle. A provider that answered *and refused*
|
||||
/// is not a failure here: it comes back with a negative `Response.status`.
|
||||
pub fn call(self: Channel, header: envelope.Header, request: []const u8, into: []u8) ?Response {
|
||||
return self.callCapability(header, request, into, null);
|
||||
}
|
||||
|
||||
/// As `call`, handing the provider a capability with the request — the only
|
||||
/// direction-crossing move kernel-ipc offers, and how `subscribe` delivers
|
||||
/// the subscriber's own endpoint.
|
||||
pub fn callCapability(
|
||||
self: Channel,
|
||||
header: envelope.Header,
|
||||
request: []const u8,
|
||||
into: []u8,
|
||||
capability: ?ipc.Handle,
|
||||
) ?Response {
|
||||
var packet: [envelope.packet_maximum]u8 = undefined;
|
||||
const framed = frame(header, request, &packet) orelse return null;
|
||||
|
||||
var reply: [envelope.packet_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(self.endpoint, framed, &reply, capability) catch return null;
|
||||
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
|
||||
const available = @min(answer.len - envelope.prefix_size, @as(usize, status.len));
|
||||
const taken = @min(available, into.len);
|
||||
@memcpy(into[0..taken], reply[envelope.prefix_size..][0..taken]);
|
||||
return .{ .status = status, .payload = into[0..taken], .capability = answer.cap };
|
||||
}
|
||||
|
||||
/// Push one event packet and return immediately — no reply owed, and a slow
|
||||
/// or dead peer can never stall the sender. Bounded by `post_maximum`: an
|
||||
/// event that does not fit is refused here rather than split, because a
|
||||
/// packet is never fragmented.
|
||||
pub fn send(self: Channel, header: envelope.Header, payload: []const u8) bool {
|
||||
var packet: [envelope.post_maximum]u8 = undefined;
|
||||
const framed = frame(header, payload, &packet) orelse return false;
|
||||
return ipc.send(self.endpoint, framed);
|
||||
}
|
||||
|
||||
/// Ask the provider what it is: the reserved `describe` verb, answered by
|
||||
/// every protocol built through `envelope.Define`. The name and version come
|
||||
/// back in `into`, which the returned `Described` borrows.
|
||||
pub fn describe(self: Channel, into: []u8) ?envelope.Described {
|
||||
var request: [envelope.packet_maximum]u8 = undefined;
|
||||
const packet = envelope.encodeDescribe(&request) orelse return null;
|
||||
|
||||
var reply: [envelope.packet_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(self.endpoint, packet, &reply, null) catch return null;
|
||||
const taken = @min(answer.len, into.len);
|
||||
@memcpy(into[0..taken], reply[0..taken]);
|
||||
return envelope.decodeDescribe(into[0..taken]);
|
||||
}
|
||||
|
||||
/// Drop the provider's endpoint and free the handle-table slot. The
|
||||
/// conversation is over the moment the capability is gone — there is nothing
|
||||
/// else holding it open.
|
||||
pub fn close(self: Channel) void {
|
||||
_ = ipc.close(self.endpoint);
|
||||
}
|
||||
};
|
||||
|
||||
/// Lay a packet down: the folded header first, then the protocol's bytes. Null
|
||||
/// when it would not fit the buffer — the same rule as `envelope`'s framing,
|
||||
/// applied where the buffer is the transport's, not the protocol's.
|
||||
fn frame(header: envelope.Header, body: []const u8, buffer: []u8) ?[]u8 {
|
||||
const total = envelope.prefix_size + body.len;
|
||||
if (total > buffer.len) return null;
|
||||
@memcpy(buffer[0..envelope.prefix_size], std.mem.asBytes(&header));
|
||||
@memcpy(buffer[envelope.prefix_size..][0..body.len], body);
|
||||
return buffer[0..total];
|
||||
}
|
||||
|
||||
// --- tests ------------------------------------------------------------------
|
||||
//
|
||||
// The syscall half cannot run on the host, and there is no registry to reach
|
||||
// until P2 — so what is testable here is the framing, which is the part with
|
||||
// arithmetic in it.
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
test "a framed packet is the header followed by the protocol's bytes" {
|
||||
var buffer: [envelope.packet_maximum]u8 = undefined;
|
||||
const header = envelope.Header{ .operation = envelope.first_protocol_operation, .target = 9 };
|
||||
const packet = frame(header, "body", &buffer).?;
|
||||
|
||||
try testing.expectEqual(envelope.prefix_size + "body".len, packet.len);
|
||||
const decoded = envelope.headerOf(packet).?;
|
||||
try testing.expectEqual(envelope.first_protocol_operation, decoded.operation);
|
||||
try testing.expectEqual(@as(u64, 9), decoded.target);
|
||||
try testing.expectEqualStrings("body", packet[envelope.prefix_size..]);
|
||||
}
|
||||
|
||||
test "framing refuses a packet that would not fit rather than truncating it" {
|
||||
var post: [envelope.post_maximum]u8 = undefined;
|
||||
const header = envelope.Header{ .operation = envelope.first_protocol_operation };
|
||||
const body = [_]u8{0} ** (envelope.post_maximum - envelope.prefix_size);
|
||||
const one_too_many = body ++ [_]u8{0};
|
||||
|
||||
try testing.expect(frame(header, &body, &post) != null);
|
||||
try testing.expect(frame(header, &one_too_many, &post) == null);
|
||||
}
|
||||
|
|
@ -39,6 +39,7 @@ fn kindFromWire(value: u32) Kind {
|
|||
@intFromEnum(Kind.symbolic_link) => .symbolic_link,
|
||||
@intFromEnum(Kind.fifo) => .fifo,
|
||||
@intFromEnum(Kind.socket) => .socket,
|
||||
@intFromEnum(Kind.protocol) => .protocol,
|
||||
else => .regular,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,10 @@
|
|||
//! every conversation depend on the contract by name; neither reaches into the
|
||||
//! other's files. Pure flat wire types: no protocol module imports anything.
|
||||
//!
|
||||
//! One module here is not a protocol but the shape the others are written in:
|
||||
//!
|
||||
//! envelope : the packet prefix + comptime Define (docs/os-development/protocol-namespace.md)
|
||||
//!
|
||||
//! vfs-protocol : the VFS server <-> the file layer (unistd/stdio)
|
||||
//! input-protocol : the input fan-out service <-> sources + subscribers
|
||||
//! block-protocol : a filesystem <-> a block driver (usb-storage)
|
||||
|
|
@ -16,6 +20,9 @@ const std = @import("std");
|
|||
|
||||
pub fn build(b: *std.Build) void {
|
||||
for ([_]struct { name: []const u8, root: []const u8 }{
|
||||
// Not a protocol, hence not `-protocol`: the envelope is what a
|
||||
// protocol is defined *through*.
|
||||
.{ .name = "envelope", .root = "envelope/envelope.zig" },
|
||||
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
|
||||
.{ .name = "input-protocol", .root = "input/input-protocol.zig" },
|
||||
.{ .name = "block-protocol", .root = "block/block-protocol.zig" },
|
||||
|
|
@ -32,6 +39,7 @@ pub fn build(b: *std.Build) void {
|
|||
// its aggregate test step.
|
||||
const test_step = b.step("test", "Run the protocol unit tests");
|
||||
for ([_][]const u8{
|
||||
"envelope/envelope.zig", // framing round trips, verb numbering, dispatch, the floors
|
||||
"vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
|
||||
"display/display-protocol.zig", // pack(): native pixel encoding per format
|
||||
}) |root| {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,915 @@
|
|||
//! The envelope — the fixed prefix every danos packet begins with, and the
|
||||
//! comptime `Define` that builds a protocol out of it. Layer L2 of
|
||||
//! [communication.md](../../../docs/os-development/communication.md); the
|
||||
//! authoritative description is
|
||||
//! [protocol-namespace.md](../../../docs/os-development/protocol-namespace.md).
|
||||
//!
|
||||
//! This is the one module in the protocol domain that is not itself a protocol:
|
||||
//! it is the shape every protocol is expressed in. A protocol module hands
|
||||
//! `Define` its verbs and events and gets back numbered operations (never
|
||||
//! colliding with the reserved range), typed encode/decode helpers, a
|
||||
//! provider-side dispatch table that answers `describe` on its own — and, the
|
||||
//! point of the exercise, compile-time proof that none of its packets can
|
||||
//! exceed the transport floor. Errors that used to surface as runtime
|
||||
//! truncation are compile errors, and "packets never fragment" is enforced at
|
||||
//! the source rather than by review.
|
||||
//!
|
||||
//! **The prefix is folded, never stacked.** A `request`, `reply`, or `payload`
|
||||
//! type names the bytes that follow the prefix — never the whole packet. The
|
||||
//! verb and the object being addressed live in the prefix, so a protocol type
|
||||
//! carries neither an `operation` field of its own nor a nested `Header`;
|
||||
//! `rejectStacking` refuses one at compile time, and every size check adds
|
||||
//! `prefix_size` exactly once.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
// --- the prefix -------------------------------------------------------------
|
||||
|
||||
/// Every packet a danos protocol transmits begins with this header — requests
|
||||
/// on the synchronous call path, event packets on the asynchronous push path.
|
||||
/// A reply spends the same 16 bytes on `Status` instead.
|
||||
pub const Header = extern struct {
|
||||
/// The verb. Values below `first_protocol_operation` are the reserved
|
||||
/// universal verbs, which mean the same thing in every protocol.
|
||||
operation: u32,
|
||||
_padding: u32 = 0,
|
||||
/// **Object** addressing within the peer, never party addressing: which of
|
||||
/// the peer's objects this packet operates on — a volume, a layer, a node,
|
||||
/// a device. `0` addresses the provider itself, and a protocol with no
|
||||
/// objects never uses the field. *Which* party is at the other end was
|
||||
/// decided once, when the channel was opened, and *who sent this* is the
|
||||
/// kernel-stamped badge; neither is ever written here, which is what keeps
|
||||
/// the source unforgeable.
|
||||
target: u64 = 0,
|
||||
};
|
||||
|
||||
/// Every reply begins with this. `len` counts the bytes that follow: the
|
||||
/// reply's fixed part plus whatever variable tail the operation defines.
|
||||
pub const Status = extern struct {
|
||||
status: i32, // 0, or a negative errno
|
||||
_padding: u32 = 0,
|
||||
len: u32 = 0,
|
||||
_padding2: u32 = 0,
|
||||
};
|
||||
|
||||
/// The fixed prefix every packet spends — `Header` on a request or an event,
|
||||
/// `Status` on a reply. One constant, because the two are deliberately the same
|
||||
/// width: the packet budget does not depend on the direction.
|
||||
pub const prefix_size: usize = @sizeOf(Header);
|
||||
|
||||
comptime {
|
||||
if (@sizeOf(Header) != 16 or @sizeOf(Status) != 16)
|
||||
@compileError("the envelope prefix is 16 bytes in both directions");
|
||||
}
|
||||
|
||||
// --- the reserved verbs -----------------------------------------------------
|
||||
|
||||
/// Reserved verbs, answered by every provider. `Define` numbers a protocol's
|
||||
/// own verbs from `first_protocol_operation`, so no protocol can reach in here.
|
||||
pub const operation_describe: u32 = 0; // -> protocol name, version, target kinds
|
||||
pub const operation_enumerate: u32 = 1; // -> the current targets, one per reply page
|
||||
pub const operation_subscribe: u32 = 2; // capability = the subscriber's endpoint
|
||||
pub const operation_unsubscribe: u32 = 3;
|
||||
pub const first_protocol_operation: u32 = 16;
|
||||
|
||||
/// The `describe` reply's fixed part, followed inline by `name_len` bytes of the
|
||||
/// protocol's name. This is the version handshake: the version is asked for
|
||||
/// once, at connect time, rather than re-carried by every packet out of a
|
||||
/// 256-byte budget.
|
||||
pub const Description = extern struct {
|
||||
version: u32,
|
||||
operation_count: u32,
|
||||
event_count: u32,
|
||||
name_len: u32,
|
||||
};
|
||||
|
||||
/// Longest protocol name a `describe` reply can carry.
|
||||
pub const name_maximum: usize = packet_maximum - prefix_size - @sizeOf(Description);
|
||||
|
||||
// --- the transport floor ----------------------------------------------------
|
||||
|
||||
/// The packet budget every protocol may assume on *any* transport. These are
|
||||
/// the kernel-ipc transport's limits — `MESSAGE_MAXIMUM` and `POST_MAXIMUM` in
|
||||
/// system/kernel/ipc-synchronous.zig — restated here because the kernel keeps
|
||||
/// them private and a protocol has to compile against something. A fatter
|
||||
/// transport raises its own ceiling; the floor does not move, so a protocol
|
||||
/// that fits here fits everywhere (communication.md: ceilings are transport
|
||||
/// properties, the floor is the protocol's contract).
|
||||
pub const packet_maximum: usize = 256; // one request or one reply (ipc_call)
|
||||
pub const post_maximum: usize = 64; // one event packet (ipc_send)
|
||||
|
||||
/// Whether a request or reply whose fixed part is `T` fits the call floor once
|
||||
/// the prefix is counted. The folded rule in one line: `prefix_size` is added
|
||||
/// exactly once, because `T` describes only what follows it. Exported so the
|
||||
/// rule itself is testable — `Define` enforces it as a compile error.
|
||||
pub fn fitsPacket(comptime T: type) bool {
|
||||
return prefix_size + @sizeOf(T) <= packet_maximum;
|
||||
}
|
||||
|
||||
/// The same, against the much smaller push floor an event packet lives within.
|
||||
pub fn fitsPost(comptime T: type) bool {
|
||||
return prefix_size + @sizeOf(T) <= post_maximum;
|
||||
}
|
||||
|
||||
// --- reply statuses the envelope itself produces -----------------------------
|
||||
|
||||
/// Continued from the kernel's danos-native errno numbering
|
||||
/// (system/kernel/ipc-synchronous.zig, which ends at `EPERM` = 9), so a client
|
||||
/// reads one vocabulary whether the number came from the kernel or a provider.
|
||||
/// Positive here, sent negated in `Status.status`, as the kernel spells it.
|
||||
pub const ENOSYS: i32 = 10; // this protocol has no such operation
|
||||
pub const EPROTO: i32 = 11; // malformed packet: shorter than the verb it names
|
||||
|
||||
// --- framing ----------------------------------------------------------------
|
||||
|
||||
/// The header of a received packet, or null when it is too short to have one.
|
||||
pub fn headerOf(packet: []const u8) ?Header {
|
||||
if (packet.len < prefix_size) return null;
|
||||
return std.mem.bytesToValue(Header, packet[0..prefix_size]);
|
||||
}
|
||||
|
||||
/// The status of a received reply, or null when it is too short to have one.
|
||||
pub fn statusOf(packet: []const u8) ?Status {
|
||||
if (packet.len < prefix_size) return null;
|
||||
return std.mem.bytesToValue(Status, packet[0..prefix_size]);
|
||||
}
|
||||
|
||||
/// Frame a bare `describe` request. Protocol-independent: the reserved verbs
|
||||
/// are asked the same way of every provider.
|
||||
pub fn encodeDescribe(buffer: []u8) ?[]u8 {
|
||||
const header = Header{ .operation = operation_describe };
|
||||
return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer);
|
||||
}
|
||||
|
||||
/// A decoded `describe` reply: the fixed part, plus the name that follows it.
|
||||
pub const Described = struct {
|
||||
description: Description,
|
||||
name: []const u8,
|
||||
};
|
||||
|
||||
/// Decode a `describe` reply packet. Null if it failed, was truncated, or is
|
||||
/// not a description at all.
|
||||
pub fn decodeDescribe(packet: []const u8) ?Described {
|
||||
const status = statusOf(packet) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
const body = packet[prefix_size..];
|
||||
if (body.len < @sizeOf(Description)) return null;
|
||||
const description = std.mem.bytesToValue(Description, body[0..@sizeOf(Description)]);
|
||||
const name = body[@sizeOf(Description)..];
|
||||
if (name.len < description.name_len) return null;
|
||||
return .{ .description = description, .name = name[0..description.name_len] };
|
||||
}
|
||||
|
||||
/// The single framing point: prefix, then the fixed part, then the variable
|
||||
/// tail, contiguous in one buffer. Null when the packet would not fit — a
|
||||
/// packet is never split, so not fitting is a failure, not a continuation.
|
||||
fn frame(prefix: []const u8, fixed: []const u8, tail: []const u8, buffer: []u8) ?[]u8 {
|
||||
const total = prefix.len + fixed.len + tail.len;
|
||||
if (total > buffer.len) return null;
|
||||
@memcpy(buffer[0..prefix.len], prefix);
|
||||
@memcpy(buffer[prefix.len..][0..fixed.len], fixed);
|
||||
@memcpy(buffer[prefix.len + fixed.len ..][0..tail.len], tail);
|
||||
return buffer[0..total];
|
||||
}
|
||||
|
||||
/// The bytes of a fixed part — empty for `void`, which is how an operation says
|
||||
/// "nothing but the verb".
|
||||
fn bytesOf(comptime T: type, value: *const T) []const u8 {
|
||||
if (@sizeOf(T) == 0) return &.{};
|
||||
return @as([*]const u8, @ptrCast(value))[0..@sizeOf(T)];
|
||||
}
|
||||
|
||||
/// Read a fixed part out of a packet body. A zero-sized part always succeeds
|
||||
/// (there is nothing to be short of); anything else needs its full width.
|
||||
fn valueOf(comptime T: type, body: []const u8) ?T {
|
||||
if (@sizeOf(T) == 0) return @as(T, undefined);
|
||||
if (body.len < @sizeOf(T)) return null;
|
||||
return std.mem.bytesToValue(T, body[0..@sizeOf(T)]);
|
||||
}
|
||||
|
||||
// --- the specification ------------------------------------------------------
|
||||
|
||||
/// One verb of a protocol. `request` and `reply` describe the bytes *after* the
|
||||
/// prefix; either may be `void`, meaning the verb (and its target) says it all.
|
||||
pub const OperationSpecification = struct {
|
||||
name: []const u8,
|
||||
request: type = void,
|
||||
reply: type = void,
|
||||
};
|
||||
|
||||
/// One event a provider pushes to its subscribers. `payload` is the bytes after
|
||||
/// the `Header`, and the whole packet must fit the push floor.
|
||||
pub const EventSpecification = struct {
|
||||
name: []const u8,
|
||||
payload: type = void,
|
||||
};
|
||||
|
||||
/// What `Define` is given: the contract, whole.
|
||||
pub const Specification = struct {
|
||||
/// The contract's name — the same word as its `/protocol/<name>` leaf and
|
||||
/// its `library/protocol/` module.
|
||||
name: []const u8,
|
||||
version: u32,
|
||||
operations: []const OperationSpecification = &.{},
|
||||
events: []const EventSpecification = &.{},
|
||||
};
|
||||
|
||||
const reserved_names = [_][]const u8{ "describe", "enumerate", "subscribe", "unsubscribe" };
|
||||
|
||||
fn isReservedName(comptime name: []const u8) bool {
|
||||
for (reserved_names) |reserved| {
|
||||
if (std.mem.eql(u8, reserved, name)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Refuse a protocol type that carries the prefix inside itself. The header is
|
||||
/// folded into every packet, so a type that also holds one would send it twice
|
||||
/// and re-invent per-protocol addressing — the mistake the envelope exists to
|
||||
/// prevent.
|
||||
fn rejectStacking(comptime protocol: []const u8, comptime verb: []const u8, comptime T: type) void {
|
||||
switch (@typeInfo(T)) {
|
||||
.@"struct" => |info| for (info.fields) |field| {
|
||||
if (field.type == Header or field.type == Status) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}', verb '{s}': the envelope prefix is folded, not stacked — " ++
|
||||
"drop the {s} field '{s}' and use the packet's own Header.operation / Header.target",
|
||||
.{ protocol, verb, @typeName(field.type), field.name },
|
||||
));
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn nullDefault(comptime T: type) *const anyopaque {
|
||||
const empty: ?T = null;
|
||||
return @ptrCast(&empty);
|
||||
}
|
||||
|
||||
// --- Define -----------------------------------------------------------------
|
||||
|
||||
/// Build a protocol from its specification. Everything below happens at compile
|
||||
/// time; the generated type is what both sides of the conversation import.
|
||||
///
|
||||
/// ```zig
|
||||
/// pub const Protocol = envelope.Define(.{
|
||||
/// .name = "display",
|
||||
/// .version = 1,
|
||||
/// .operations = &.{
|
||||
/// .{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
|
||||
/// .{ .name = "blit", .request = Blit, .reply = void },
|
||||
/// },
|
||||
/// .events = &.{
|
||||
/// .{ .name = "layer_lost", .payload = LayerLost },
|
||||
/// },
|
||||
/// });
|
||||
/// ```
|
||||
///
|
||||
/// Refused at compile time, each with the protocol, the verb, and the numbers
|
||||
/// named in the message:
|
||||
///
|
||||
/// - a request or reply that does not fit `packet_maximum` once `prefix_size`
|
||||
/// is added (`.request = extern struct { bytes: [241]u8 }` — 241 + 16 = 257);
|
||||
/// - an event payload that does not fit `post_maximum` the same way
|
||||
/// (`.payload = extern struct { bytes: [49]u8 }` — 49 + 16 = 65);
|
||||
/// - a type that stacks the prefix instead of folding it (a `Header` field);
|
||||
/// - a verb named after a reserved one, or named twice.
|
||||
///
|
||||
/// A variable tail is bounded at *run* time instead, by `encodeRequest` and its
|
||||
/// siblings, because only the caller knows how long it is.
|
||||
pub fn Define(comptime specification: Specification) type {
|
||||
comptime {
|
||||
if (specification.name.len == 0) @compileError("a protocol needs a name");
|
||||
if (specification.name.len > name_maximum) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}': the name is {d} bytes, and a describe reply carries at most {d}",
|
||||
.{ specification.name, specification.name.len, name_maximum },
|
||||
));
|
||||
|
||||
for (specification.operations, 0..) |operation, index| {
|
||||
if (isReservedName(operation.name)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}': '{s}' is a reserved universal verb — the envelope already answers it",
|
||||
.{ specification.name, operation.name },
|
||||
));
|
||||
for (specification.operations[0..index]) |earlier| {
|
||||
if (std.mem.eql(u8, earlier.name, operation.name)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}': operation '{s}' is declared twice",
|
||||
.{ specification.name, operation.name },
|
||||
));
|
||||
}
|
||||
rejectStacking(specification.name, operation.name, operation.request);
|
||||
rejectStacking(specification.name, operation.name, operation.reply);
|
||||
if (!fitsPacket(operation.request)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}', operation '{s}': the request is {d} bytes and the header {d}, " ++
|
||||
"over the {d}-byte call floor — packets never fragment, so this has to shrink " ++
|
||||
"or move its bulk to shared memory",
|
||||
.{ specification.name, operation.name, @sizeOf(operation.request), prefix_size, packet_maximum },
|
||||
));
|
||||
if (!fitsPacket(operation.reply)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}', operation '{s}': the reply is {d} bytes and the status header {d}, " ++
|
||||
"over the {d}-byte call floor",
|
||||
.{ specification.name, operation.name, @sizeOf(operation.reply), prefix_size, packet_maximum },
|
||||
));
|
||||
}
|
||||
|
||||
for (specification.events, 0..) |event, index| {
|
||||
for (specification.events[0..index]) |earlier| {
|
||||
if (std.mem.eql(u8, earlier.name, event.name)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}': event '{s}' is declared twice",
|
||||
.{ specification.name, event.name },
|
||||
));
|
||||
}
|
||||
rejectStacking(specification.name, event.name, event.payload);
|
||||
if (!fitsPost(event.payload)) @compileError(std.fmt.comptimePrint(
|
||||
"protocol '{s}', event '{s}': the payload is {d} bytes and the header {d}, " ++
|
||||
"over the {d}-byte push floor — an event carries the header too, so it is the " ++
|
||||
"payload that has to give",
|
||||
.{ specification.name, event.name, @sizeOf(event.payload), prefix_size, post_maximum },
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return struct {
|
||||
pub const protocol_name: []const u8 = specification.name;
|
||||
pub const version: u32 = specification.version;
|
||||
|
||||
/// What a provider sizes its receive and reply buffers to. A packet's
|
||||
/// fixed part may be far smaller, but any caller may send up to the
|
||||
/// floor and a short buffer truncates rather than refuses.
|
||||
pub const message_maximum: usize = packet_maximum;
|
||||
|
||||
/// The widest packet this protocol's fixed parts can actually produce,
|
||||
/// prefix included — a diagnostic, and what a test pins.
|
||||
pub const request_maximum: usize = widest(specification.operations, .request);
|
||||
pub const reply_maximum: usize = widest(specification.operations, .reply);
|
||||
pub const event_maximum: usize = blk: {
|
||||
var widest_event: usize = prefix_size;
|
||||
for (specification.events) |event| widest_event = @max(widest_event, prefix_size + @sizeOf(event.payload));
|
||||
break :blk widest_event;
|
||||
};
|
||||
|
||||
/// This protocol's verbs, numbered from `first_protocol_operation` in
|
||||
/// declaration order.
|
||||
pub const Operation = numbered(specification.operations, "name");
|
||||
|
||||
/// This protocol's events, numbered from `first_protocol_operation` in
|
||||
/// their **own** space. Events travel only provider → subscriber over
|
||||
/// `ipc_send` and operations only client → provider over `ipc_call`, so
|
||||
/// the direction already tells the two apart; separate spaces mean
|
||||
/// appending an operation can never renumber a shipped event.
|
||||
pub const Event = numbered(specification.events, "name");
|
||||
|
||||
/// The bytes after the `Header` on a request for `operation`.
|
||||
pub fn RequestOf(comptime operation: Operation) type {
|
||||
return specification.operations[indexOf(@intFromEnum(operation))].request;
|
||||
}
|
||||
|
||||
/// The bytes after the `Status` on the reply to `operation`.
|
||||
pub fn ReplyOf(comptime operation: Operation) type {
|
||||
return specification.operations[indexOf(@intFromEnum(operation))].reply;
|
||||
}
|
||||
|
||||
/// The bytes after the `Header` on an `event` packet.
|
||||
pub fn PayloadOf(comptime event: Event) type {
|
||||
return specification.events[indexOf(@intFromEnum(event))].payload;
|
||||
}
|
||||
|
||||
// --- client side ----------------------------------------------------
|
||||
|
||||
/// Frame `[Header][request][tail]`. `tail` is the variable part (a path,
|
||||
/// write bytes); pass `&.{}` when the verb has none. Null if the packet
|
||||
/// would exceed the buffer or the call floor.
|
||||
pub fn encodeRequest(
|
||||
comptime operation: Operation,
|
||||
target: u64,
|
||||
request: RequestOf(operation),
|
||||
tail: []const u8,
|
||||
buffer: []u8,
|
||||
) ?[]u8 {
|
||||
const header = Header{ .operation = @intFromEnum(operation), .target = target };
|
||||
const packet = frame(std.mem.asBytes(&header), bytesOf(RequestOf(operation), &request), tail, buffer) orelse return null;
|
||||
return if (packet.len > packet_maximum) null else packet;
|
||||
}
|
||||
|
||||
/// Frame `[Status][reply][tail]` — the provider's answer, for a provider
|
||||
/// that composes its own reply rather than using `Provider.dispatch`.
|
||||
pub fn encodeReply(
|
||||
comptime operation: Operation,
|
||||
status: i32,
|
||||
reply: ReplyOf(operation),
|
||||
tail: []const u8,
|
||||
buffer: []u8,
|
||||
) ?[]u8 {
|
||||
const fixed = bytesOf(ReplyOf(operation), &reply);
|
||||
const head = Status{ .status = status, .len = @intCast(fixed.len + tail.len) };
|
||||
const packet = frame(std.mem.asBytes(&head), fixed, tail, buffer) orelse return null;
|
||||
return if (packet.len > packet_maximum) null else packet;
|
||||
}
|
||||
|
||||
/// Frame `[Header][payload]` for an asynchronous push. Null if it would
|
||||
/// exceed the buffer or the push floor — an event that does not fit is
|
||||
/// dropped at the source, never split.
|
||||
pub fn encodeEvent(
|
||||
comptime event: Event,
|
||||
target: u64,
|
||||
payload: PayloadOf(event),
|
||||
buffer: []u8,
|
||||
) ?[]u8 {
|
||||
const header = Header{ .operation = @intFromEnum(event), .target = target };
|
||||
const packet = frame(std.mem.asBytes(&header), bytesOf(PayloadOf(event), &payload), &.{}, buffer) orelse return null;
|
||||
return if (packet.len > post_maximum) null else packet;
|
||||
}
|
||||
|
||||
/// Which of this protocol's verbs a packet names — null for a reserved
|
||||
/// verb, or for a number this protocol does not define.
|
||||
pub fn operationOf(packet: []const u8) ?Operation {
|
||||
const header = headerOf(packet) orelse return null;
|
||||
const index = header.operation -% first_protocol_operation;
|
||||
if (header.operation < first_protocol_operation or index >= specification.operations.len) return null;
|
||||
return @enumFromInt(header.operation);
|
||||
}
|
||||
|
||||
/// Which of this protocol's events a pushed packet carries.
|
||||
pub fn eventOf(packet: []const u8) ?Event {
|
||||
const header = headerOf(packet) orelse return null;
|
||||
const index = header.operation -% first_protocol_operation;
|
||||
if (header.operation < first_protocol_operation or index >= specification.events.len) return null;
|
||||
return @enumFromInt(header.operation);
|
||||
}
|
||||
|
||||
/// The fixed request part of a packet already known to name `operation`.
|
||||
pub fn decodeRequest(comptime operation: Operation, packet: []const u8) ?RequestOf(operation) {
|
||||
if (packet.len < prefix_size) return null;
|
||||
return valueOf(RequestOf(operation), packet[prefix_size..]);
|
||||
}
|
||||
|
||||
/// The bytes after the fixed request part — empty when there are none.
|
||||
pub fn requestTail(comptime operation: Operation, packet: []const u8) []const u8 {
|
||||
const start = prefix_size + @sizeOf(RequestOf(operation));
|
||||
return if (packet.len <= start) &.{} else packet[start..];
|
||||
}
|
||||
|
||||
/// The fixed reply part of a reply packet. Null on a short packet; the
|
||||
/// caller checks `statusOf(packet).status` for the provider's verdict.
|
||||
pub fn decodeReply(comptime operation: Operation, packet: []const u8) ?ReplyOf(operation) {
|
||||
if (packet.len < prefix_size) return null;
|
||||
return valueOf(ReplyOf(operation), packet[prefix_size..]);
|
||||
}
|
||||
|
||||
/// The bytes after the fixed reply part, clipped to what `Status.len`
|
||||
/// says actually arrived.
|
||||
pub fn replyTail(comptime operation: Operation, packet: []const u8) []const u8 {
|
||||
const status = statusOf(packet) orelse return &.{};
|
||||
const start = prefix_size + @sizeOf(ReplyOf(operation));
|
||||
const end = @min(packet.len, prefix_size + @as(usize, status.len));
|
||||
return if (end <= start) &.{} else packet[start..end];
|
||||
}
|
||||
|
||||
/// The payload of a pushed packet already known to carry `event`.
|
||||
pub fn decodeEvent(comptime event: Event, packet: []const u8) ?PayloadOf(event) {
|
||||
if (packet.len < prefix_size) return null;
|
||||
return valueOf(PayloadOf(event), packet[prefix_size..]);
|
||||
}
|
||||
|
||||
// --- provider side --------------------------------------------------
|
||||
|
||||
/// This protocol's dispatch table, bound to the provider's own state
|
||||
/// type. `describe` is answered here, from the specification; every verb
|
||||
/// this provider left null answers `-ENOSYS`, which is what makes the
|
||||
/// reserved verbs mean the same thing at every provider in the system.
|
||||
///
|
||||
/// ```zig
|
||||
/// const Serve = Protocol.Provider(*Server);
|
||||
/// const handlers = Serve.Handlers{ .blit = onBlit, .configure_layer = onConfigureLayer };
|
||||
/// const reply_len = Serve.dispatch(server, handlers, message, sender, capability, reply);
|
||||
/// ```
|
||||
///
|
||||
/// A handler returns the number of `answer.tail()` bytes it wrote, or a
|
||||
/// negative errno.
|
||||
pub fn Provider(comptime Context: type) type {
|
||||
return struct {
|
||||
/// A reserved verb a provider chooses to implement itself.
|
||||
/// `enumerate` writes its targets into the tail; `subscribe`
|
||||
/// takes the subscriber's endpoint from `invocation.capability`.
|
||||
pub const ReservedHandler = *const fn (Context, Invocation(void), Answer(void)) isize;
|
||||
|
||||
/// One optional handler per verb, named exactly as the verb,
|
||||
/// plus the reserved verbs the envelope cannot answer alone.
|
||||
pub const Handlers = handlerTable(Context);
|
||||
|
||||
/// Answer one received packet: writes `[Status][reply][tail]`
|
||||
/// into `reply` and returns its length. Zero means the reply
|
||||
/// buffer could not even hold a status, so nothing was written.
|
||||
pub fn dispatch(
|
||||
context: Context,
|
||||
handlers: Handlers,
|
||||
packet: []const u8,
|
||||
sender: u32,
|
||||
capability: ?usize,
|
||||
reply: []u8,
|
||||
) usize {
|
||||
if (reply.len < prefix_size) return 0;
|
||||
const header = headerOf(packet) orelse return refuse(reply, -EPROTO);
|
||||
const body = packet[prefix_size..];
|
||||
|
||||
if (header.operation == operation_describe) return describeInto(reply);
|
||||
|
||||
inline for (specification.operations, 0..) |operation, index| {
|
||||
if (header.operation == first_protocol_operation + index) {
|
||||
return invoke(
|
||||
Context,
|
||||
operation.request,
|
||||
operation.reply,
|
||||
@field(handlers, operation.name),
|
||||
context,
|
||||
header.target,
|
||||
body,
|
||||
sender,
|
||||
capability,
|
||||
reply,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const reserved: ?ReservedHandler = switch (header.operation) {
|
||||
operation_enumerate => handlers.enumerate,
|
||||
operation_subscribe => handlers.subscribe,
|
||||
operation_unsubscribe => handlers.unsubscribe,
|
||||
else => null,
|
||||
};
|
||||
return invoke(Context, void, void, reserved, context, header.target, body, sender, capability, reply);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Shared by the protocol verbs and the reserved ones: decode, hand the
|
||||
// handler a typed invocation, stamp the status. One place, so a reserved
|
||||
// verb and a protocol verb behave identically.
|
||||
fn invoke(
|
||||
comptime Context: type,
|
||||
comptime RequestType: type,
|
||||
comptime ReplyType: type,
|
||||
handler: ?*const fn (Context, Invocation(RequestType), Answer(ReplyType)) isize,
|
||||
context: Context,
|
||||
target: u64,
|
||||
body: []const u8,
|
||||
sender: u32,
|
||||
capability: ?usize,
|
||||
reply: []u8,
|
||||
) usize {
|
||||
const call = handler orelse return refuse(reply, -ENOSYS);
|
||||
const request = valueOf(RequestType, body) orelse return refuse(reply, -EPROTO);
|
||||
if (reply.len < prefix_size + @sizeOf(ReplyType)) return refuse(reply, -EPROTO);
|
||||
const produced = call(context, .{
|
||||
.target = target,
|
||||
.request = request,
|
||||
.tail = body[@min(@sizeOf(RequestType), body.len)..],
|
||||
.sender = sender,
|
||||
.capability = capability,
|
||||
}, .{ .buffer = reply[prefix_size..] });
|
||||
if (produced < 0) return refuse(reply, @intCast(produced));
|
||||
return succeed(reply, @sizeOf(ReplyType) + @as(usize, @intCast(produced)));
|
||||
}
|
||||
|
||||
fn describeInto(reply: []u8) usize {
|
||||
const description = Description{
|
||||
.version = specification.version,
|
||||
.operation_count = specification.operations.len,
|
||||
.event_count = specification.events.len,
|
||||
.name_len = specification.name.len,
|
||||
};
|
||||
const total = @sizeOf(Description) + specification.name.len;
|
||||
if (reply.len < prefix_size + total) return refuse(reply, -EPROTO);
|
||||
@memcpy(reply[prefix_size..][0..@sizeOf(Description)], std.mem.asBytes(&description));
|
||||
@memcpy(reply[prefix_size + @sizeOf(Description) ..][0..specification.name.len], specification.name);
|
||||
return succeed(reply, total);
|
||||
}
|
||||
|
||||
// "describe" is answered by the envelope, so it is the one reserved verb
|
||||
// with no slot in the table.
|
||||
const implementable_reserved = [_][]const u8{ "enumerate", "subscribe", "unsubscribe" };
|
||||
|
||||
fn handlerTable(comptime Context: type) type {
|
||||
const count = specification.operations.len + implementable_reserved.len;
|
||||
var names: [count][]const u8 = undefined;
|
||||
var types: [count]type = undefined;
|
||||
var attributes: [count]std.builtin.Type.StructField.Attributes = undefined;
|
||||
for (specification.operations, 0..) |operation, index| {
|
||||
const Handler = *const fn (Context, Invocation(operation.request), Answer(operation.reply)) isize;
|
||||
names[index] = operation.name;
|
||||
types[index] = ?Handler;
|
||||
attributes[index] = .{ .default_value_ptr = nullDefault(Handler) };
|
||||
}
|
||||
const Reserved = *const fn (Context, Invocation(void), Answer(void)) isize;
|
||||
for (implementable_reserved, 0..) |name, offset| {
|
||||
const slot = specification.operations.len + offset;
|
||||
names[slot] = name;
|
||||
types[slot] = ?Reserved;
|
||||
attributes[slot] = .{ .default_value_ptr = nullDefault(Reserved) };
|
||||
}
|
||||
const frozen_names = names;
|
||||
const frozen_types = types;
|
||||
const frozen_attributes = attributes;
|
||||
return @Struct(.auto, null, &frozen_names, &frozen_types, &frozen_attributes);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// --- the provider's view of one packet --------------------------------------
|
||||
|
||||
/// What a provider's handler is given.
|
||||
pub fn Invocation(comptime RequestType: type) type {
|
||||
return struct {
|
||||
/// Object addressing within this provider — the packet's `Header.target`.
|
||||
target: u64,
|
||||
/// The fixed request part, already decoded.
|
||||
request: RequestType,
|
||||
/// The bytes after it: a path, write data, a name.
|
||||
tail: []const u8,
|
||||
/// The kernel-stamped badge of the caller. The only source identity
|
||||
/// there is — no protocol defines a sender field — so per-client state
|
||||
/// is keyed on this.
|
||||
sender: u32,
|
||||
/// A capability the call carried: a subscriber's endpoint, a DMA
|
||||
/// region. Only the synchronous call path can move one.
|
||||
capability: ?usize,
|
||||
};
|
||||
}
|
||||
|
||||
/// Where a provider's handler writes its answer. The `Status` in front of it is
|
||||
/// the dispatcher's to stamp — a handler never writes its own.
|
||||
pub fn Answer(comptime ReplyType: type) type {
|
||||
return struct {
|
||||
buffer: []u8,
|
||||
|
||||
const fixed_size = @sizeOf(ReplyType);
|
||||
|
||||
/// Write the fixed reply part. A `void` reply writes nothing.
|
||||
pub fn set(self: @This(), reply: ReplyType) void {
|
||||
if (fixed_size == 0) return;
|
||||
@memcpy(self.buffer[0..fixed_size], bytesOf(ReplyType, &reply));
|
||||
}
|
||||
|
||||
/// Room for the variable tail; the handler returns how much of it it used.
|
||||
pub fn tail(self: @This()) []u8 {
|
||||
return self.buffer[fixed_size..];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
fn refuse(reply: []u8, status: i32) usize {
|
||||
const head = Status{ .status = status, .len = 0 };
|
||||
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
|
||||
return prefix_size;
|
||||
}
|
||||
|
||||
fn succeed(reply: []u8, len: usize) usize {
|
||||
const head = Status{ .status = 0, .len = @intCast(len) };
|
||||
@memcpy(reply[0..prefix_size], std.mem.asBytes(&head));
|
||||
return prefix_size + len;
|
||||
}
|
||||
|
||||
/// The widest `[prefix][fixed]` over a set of operations, on one side.
|
||||
fn widest(comptime operations: []const OperationSpecification, comptime side: enum { request, reply }) usize {
|
||||
var found: usize = prefix_size;
|
||||
for (operations) |operation| {
|
||||
const size = switch (side) {
|
||||
.request => @sizeOf(operation.request),
|
||||
.reply => @sizeOf(operation.reply),
|
||||
};
|
||||
found = @max(found, prefix_size + size);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/// An enum over `specifications`, tagged by their `name` field, numbered from
|
||||
/// `first_protocol_operation` in declaration order.
|
||||
fn numbered(comptime specifications: anytype, comptime field: []const u8) type {
|
||||
var names: [specifications.len][]const u8 = undefined;
|
||||
var values: [specifications.len]u32 = undefined;
|
||||
for (specifications, 0..) |specification, index| {
|
||||
names[index] = @field(specification, field);
|
||||
values[index] = first_protocol_operation + index;
|
||||
}
|
||||
const frozen_names = names;
|
||||
const frozen_values = values;
|
||||
return @Enum(u32, .exhaustive, &frozen_names, &frozen_values);
|
||||
}
|
||||
|
||||
/// A verb's position in its declaration list, from its wire number.
|
||||
fn indexOf(comptime operation: u32) usize {
|
||||
return operation - first_protocol_operation;
|
||||
}
|
||||
|
||||
// --- tests ------------------------------------------------------------------
|
||||
|
||||
const testing = std.testing;
|
||||
|
||||
const Produce = extern struct { count: u32, flags: u32 = 0 };
|
||||
const Produced = extern struct { total: u64 };
|
||||
const Changed = extern struct { kind: u32, value: u32 };
|
||||
|
||||
const Sample = Define(.{
|
||||
.name = "sample",
|
||||
.version = 3,
|
||||
.operations = &.{
|
||||
.{ .name = "produce", .request = Produce, .reply = Produced },
|
||||
.{ .name = "reset" },
|
||||
},
|
||||
.events = &.{
|
||||
.{ .name = "changed", .payload = Changed },
|
||||
},
|
||||
});
|
||||
|
||||
test "the prefix is 16 bytes in both directions" {
|
||||
try testing.expectEqual(@as(usize, 16), @sizeOf(Header));
|
||||
try testing.expectEqual(@as(usize, 16), @sizeOf(Status));
|
||||
try testing.expectEqual(@as(usize, 16), prefix_size);
|
||||
try testing.expectEqual(@as(usize, 256), packet_maximum);
|
||||
try testing.expectEqual(@as(usize, 64), post_maximum);
|
||||
}
|
||||
|
||||
test "verb numbering skips the reserved range" {
|
||||
try testing.expectEqual(@as(u32, 16), first_protocol_operation);
|
||||
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Operation.produce));
|
||||
try testing.expectEqual(@as(u32, 17), @intFromEnum(Sample.Operation.reset));
|
||||
// Events are numbered in their own space, so appending an operation can
|
||||
// never renumber a shipped event.
|
||||
try testing.expectEqual(@as(u32, 16), @intFromEnum(Sample.Event.changed));
|
||||
for ([_]u32{ operation_describe, operation_enumerate, operation_subscribe, operation_unsubscribe }) |reserved| {
|
||||
try testing.expect(reserved < first_protocol_operation);
|
||||
}
|
||||
}
|
||||
|
||||
test "request round trip, header folded and tail carried" {
|
||||
var buffer: [packet_maximum]u8 = undefined;
|
||||
const packet = Sample.encodeRequest(.produce, 42, .{ .count = 7 }, "tail bytes", &buffer).?;
|
||||
try testing.expectEqual(prefix_size + @sizeOf(Produce) + "tail bytes".len, packet.len);
|
||||
|
||||
const header = headerOf(packet).?;
|
||||
try testing.expectEqual(@as(u32, 16), header.operation);
|
||||
try testing.expectEqual(@as(u64, 42), header.target);
|
||||
try testing.expectEqual(Sample.Operation.produce, Sample.operationOf(packet).?);
|
||||
|
||||
const request = Sample.decodeRequest(.produce, packet).?;
|
||||
try testing.expectEqual(@as(u32, 7), request.count);
|
||||
try testing.expectEqualStrings("tail bytes", Sample.requestTail(.produce, packet));
|
||||
}
|
||||
|
||||
test "reply round trip" {
|
||||
var buffer: [packet_maximum]u8 = undefined;
|
||||
const packet = Sample.encodeReply(.produce, 0, .{ .total = 99 }, "more", &buffer).?;
|
||||
const status = statusOf(packet).?;
|
||||
try testing.expectEqual(@as(i32, 0), status.status);
|
||||
try testing.expectEqual(@as(u32, @sizeOf(Produced) + "more".len), status.len);
|
||||
try testing.expectEqual(@as(u64, 99), Sample.decodeReply(.produce, packet).?.total);
|
||||
try testing.expectEqualStrings("more", Sample.replyTail(.produce, packet));
|
||||
}
|
||||
|
||||
test "a void request and reply carry nothing but the verb" {
|
||||
var buffer: [packet_maximum]u8 = undefined;
|
||||
const packet = Sample.encodeRequest(.reset, 0, {}, &.{}, &buffer).?;
|
||||
try testing.expectEqual(prefix_size, packet.len);
|
||||
try testing.expectEqual(Sample.Operation.reset, Sample.operationOf(packet).?);
|
||||
try testing.expectEqual(@as(usize, 0), Sample.requestTail(.reset, packet).len);
|
||||
}
|
||||
|
||||
test "event round trip within the push floor" {
|
||||
var buffer: [post_maximum]u8 = undefined;
|
||||
const packet = Sample.encodeEvent(.changed, 0, .{ .kind = 1, .value = 2 }, &buffer).?;
|
||||
try testing.expectEqual(prefix_size + @sizeOf(Changed), packet.len);
|
||||
try testing.expect(packet.len <= post_maximum);
|
||||
try testing.expectEqual(Sample.Event.changed, Sample.eventOf(packet).?);
|
||||
try testing.expectEqual(@as(u32, 2), Sample.decodeEvent(.changed, packet).?.value);
|
||||
}
|
||||
|
||||
// A provider over a trivial context, to drive the generated dispatch table.
|
||||
const Counter = struct {
|
||||
total: u64 = 0,
|
||||
|
||||
fn onProduce(self: *Counter, invocation: Invocation(Produce), answer: Answer(Produced)) isize {
|
||||
self.total += invocation.request.count;
|
||||
answer.set(.{ .total = self.total });
|
||||
const note = "counted";
|
||||
@memcpy(answer.tail()[0..note.len], note);
|
||||
return note.len;
|
||||
}
|
||||
};
|
||||
|
||||
const CounterProvider = Sample.Provider(*Counter);
|
||||
|
||||
test "dispatch reaches a handler and stamps the status" {
|
||||
var counter = Counter{};
|
||||
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
|
||||
|
||||
var request: [packet_maximum]u8 = undefined;
|
||||
const packet = Sample.encodeRequest(.produce, 0, .{ .count = 5 }, &.{}, &request).?;
|
||||
var reply: [packet_maximum]u8 = undefined;
|
||||
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
|
||||
|
||||
const answered = reply[0..len];
|
||||
try testing.expectEqual(@as(i32, 0), statusOf(answered).?.status);
|
||||
try testing.expectEqual(@as(u64, 5), Sample.decodeReply(.produce, answered).?.total);
|
||||
try testing.expectEqualStrings("counted", Sample.replyTail(.produce, answered));
|
||||
}
|
||||
|
||||
test "describe is answered by the envelope, not the provider" {
|
||||
var counter = Counter{};
|
||||
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
|
||||
|
||||
var request: [packet_maximum]u8 = undefined;
|
||||
const packet = encodeDescribe(&request).?;
|
||||
var reply: [packet_maximum]u8 = undefined;
|
||||
const len = CounterProvider.dispatch(&counter, handlers, packet, 3, null, &reply);
|
||||
|
||||
const described = decodeDescribe(reply[0..len]).?;
|
||||
try testing.expectEqualStrings("sample", described.name);
|
||||
try testing.expectEqual(@as(u32, 3), described.description.version);
|
||||
try testing.expectEqual(@as(u32, 2), described.description.operation_count);
|
||||
try testing.expectEqual(@as(u32, 1), described.description.event_count);
|
||||
}
|
||||
|
||||
test "an unimplemented or unknown verb answers -ENOSYS" {
|
||||
var counter = Counter{};
|
||||
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
|
||||
var reply: [packet_maximum]u8 = undefined;
|
||||
|
||||
// A verb this protocol declares but this provider left null.
|
||||
var request: [packet_maximum]u8 = undefined;
|
||||
const declared = Sample.encodeRequest(.reset, 0, {}, &.{}, &request).?;
|
||||
var len = CounterProvider.dispatch(&counter, handlers, declared, 3, null, &reply);
|
||||
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
|
||||
|
||||
// A number no verb of this protocol wears.
|
||||
const stranger = Header{ .operation = first_protocol_operation + 900 };
|
||||
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&stranger), 3, null, &reply);
|
||||
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
|
||||
|
||||
// A reserved verb the provider does not implement answers the same way.
|
||||
const enumerate = Header{ .operation = operation_enumerate };
|
||||
len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&enumerate), 3, null, &reply);
|
||||
try testing.expectEqual(@as(i32, -ENOSYS), statusOf(reply[0..len]).?.status);
|
||||
}
|
||||
|
||||
test "a truncated packet answers -EPROTO" {
|
||||
var counter = Counter{};
|
||||
const handlers = CounterProvider.Handlers{ .produce = Counter.onProduce };
|
||||
var reply: [packet_maximum]u8 = undefined;
|
||||
|
||||
// Names `produce`, but stops before the request it promises.
|
||||
const header = Header{ .operation = @intFromEnum(Sample.Operation.produce) };
|
||||
const len = CounterProvider.dispatch(&counter, handlers, std.mem.asBytes(&header), 3, null, &reply);
|
||||
try testing.expectEqual(@as(i32, -EPROTO), statusOf(reply[0..len]).?.status);
|
||||
}
|
||||
|
||||
// The size rule, exercised directly. `Define` turns exactly these predicates
|
||||
// into compile errors, which a test cannot catch — so the predicate is what the
|
||||
// test pins, and the boundary protocol below proves the compile-time half from
|
||||
// the other side. The negative example, spelled out: giving `Define` an
|
||||
// operation with `.request = extern struct { bytes: [241]u8 }`, or an event with
|
||||
// `.payload = extern struct { bytes: [49]u8 }`, fails to compile with the
|
||||
// protocol, the verb, and the two numbers named in the message.
|
||||
test "the floor counts the header once, and the boundary is exact" {
|
||||
try testing.expect(fitsPacket(extern struct { bytes: [240]u8 }));
|
||||
try testing.expect(!fitsPacket(extern struct { bytes: [241]u8 }));
|
||||
try testing.expect(fitsPost(extern struct { bytes: [48]u8 }));
|
||||
try testing.expect(!fitsPost(extern struct { bytes: [49]u8 }));
|
||||
try testing.expect(fitsPacket(void));
|
||||
try testing.expect(fitsPost(void));
|
||||
}
|
||||
|
||||
const WidestRequest = extern struct { bytes: [packet_maximum - prefix_size]u8 };
|
||||
const WidestEvent = extern struct { bytes: [post_maximum - prefix_size]u8 };
|
||||
|
||||
// A protocol sitting exactly on both floors. That this compiles at all is the
|
||||
// positive half of the compile-time check.
|
||||
const Boundary = Define(.{
|
||||
.name = "boundary",
|
||||
.version = 1,
|
||||
.operations = &.{.{ .name = "fill", .request = WidestRequest, .reply = WidestRequest }},
|
||||
.events = &.{.{ .name = "filled", .payload = WidestEvent }},
|
||||
});
|
||||
|
||||
test "a protocol may sit exactly on the floor" {
|
||||
try testing.expectEqual(packet_maximum, Boundary.request_maximum);
|
||||
try testing.expectEqual(packet_maximum, Boundary.reply_maximum);
|
||||
try testing.expectEqual(post_maximum, Boundary.event_maximum);
|
||||
|
||||
var buffer: [packet_maximum]u8 = undefined;
|
||||
const packet = Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0xAB) }, &.{}, &buffer).?;
|
||||
try testing.expectEqual(packet_maximum, packet.len);
|
||||
try testing.expectEqual(@as(u8, 0xAB), Boundary.decodeRequest(.fill, packet).?.bytes[239]);
|
||||
|
||||
// One byte of tail past the floor is refused at run time, not truncated.
|
||||
try testing.expect(Boundary.encodeRequest(.fill, 0, .{ .bytes = @splat(0) }, "x", &buffer) == null);
|
||||
|
||||
var post: [post_maximum]u8 = undefined;
|
||||
const event = Boundary.encodeEvent(.filled, 0, .{ .bytes = @splat(1) }, &post).?;
|
||||
try testing.expectEqual(post_maximum, event.len);
|
||||
}
|
||||
|
||||
test "a protocol's own sizes are reported prefix-included" {
|
||||
try testing.expectEqual(prefix_size + @sizeOf(Produce), Sample.request_maximum);
|
||||
try testing.expectEqual(prefix_size + @sizeOf(Produced), Sample.reply_maximum);
|
||||
try testing.expectEqual(prefix_size + @sizeOf(Changed), Sample.event_maximum);
|
||||
try testing.expectEqual(packet_maximum, Sample.message_maximum);
|
||||
try testing.expectEqualStrings("sample", Sample.protocol_name);
|
||||
}
|
||||
|
|
@ -13,8 +13,17 @@
|
|||
//! written against has retired — path routing moved into the kernel (system/kernel/vfs.zig,
|
||||
//! fs_resolve) — but the protocol module outlived it.
|
||||
|
||||
//! **An `open` reply may carry a capability.** The vfs `open` request rides
|
||||
//! `ipc_call`, and the reply direction of a call can hand back an endpoint
|
||||
//! (`ipc.callCap`'s `Reply.cap`). A file backend never uses it — FAT answers
|
||||
//! with a node id and nothing else — but a *synthetic* backend does: opening a
|
||||
//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint,
|
||||
//! which is the channel (docs/os-development/protocol-namespace.md). The
|
||||
//! convention is per-backend, not per-operation: a client that did not ask a
|
||||
//! synthetic backend simply gets no capability back, exactly as today.
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
open, // open(path) -> node id
|
||||
open, // open(path) -> node id (a synthetic backend may reply with a capability instead)
|
||||
close, // close(node)
|
||||
read, // read(node, offset, len) -> bytes
|
||||
write, // write(node, offset, bytes) -> count
|
||||
|
|
@ -44,6 +53,12 @@ pub const NodeKind = enum(u32) {
|
|||
symbolic_link = 4,
|
||||
fifo = 5,
|
||||
socket = 6,
|
||||
/// A node that names a *contract*, not a file: opening it establishes a
|
||||
/// channel to whatever process currently provides that protocol, delivered
|
||||
/// as an endpoint capability in the reply rather than a node id. This is
|
||||
/// what lives under `/protocol`; `readdir` lists these like any other node,
|
||||
/// so the tree stays browsable for diagnosis.
|
||||
protocol = 7,
|
||||
};
|
||||
|
||||
/// One directory entry, returned by `readdir`: a fixed header followed inline in
|
||||
|
|
@ -109,6 +124,9 @@ test "protocol struct sizes and node kinds" {
|
|||
const std = @import("std");
|
||||
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(NodeKind.regular));
|
||||
try std.testing.expectEqual(@as(u32, 1), @intFromEnum(NodeKind.directory));
|
||||
// Appended with the protocol namespace; every earlier value keeps its own.
|
||||
try std.testing.expectEqual(@as(u32, 6), @intFromEnum(NodeKind.socket));
|
||||
try std.testing.expectEqual(@as(u32, 7), @intFromEnum(NodeKind.protocol));
|
||||
try std.testing.expectEqual(@as(usize, 16), @sizeOf(DirectoryEntry));
|
||||
// The appended operations keep the original values.
|
||||
try std.testing.expectEqual(@as(u32, 0), @intFromEnum(Operation.open));
|
||||
|
|
|
|||
Loading…
Reference in New Issue