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:
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user