library: five protocols speak the envelope

The folded header stops being a rule in a document and becomes the layout
on the wire. Verbs number from sixteen, leaving describe, enumerate,
subscribe and unsubscribe reserved and answered the same way by every
provider — none of them writes a line to do it. What each protocol used to
carry in a field of its own now travels in the header: a vfs node and a
display layer are the packet's target, and a reply opens with a status the
envelope stamps rather than one each protocol spelled for itself.

Display gains the most. One forty-byte request had served eleven verbs, so
attach_scanout smuggled stride through x, refresh through y and format
through colour, and every coordinate crossed as a bitcast. Per-operation
structs end all three: the fields have their own names and their own signs,
and the tile payload grows to 224 bytes because the prefix shrank. Scanout
loses a message maximum of 64 it had no business declaring — it answers
calls, and the floor for a call is 256 — and virtio-gpu stops hard-coding
that number at its harness.

Two changes are semantic rather than notational. A directory now ends at an
entry with no name, because the fixed part of a reply always travels and a
zero-length reply no longer exists to mean anything. And input joins the
service harness, the last loop in the tree that answered no ping and heard
no terminate; its subscriber table, its pruning and its fan-out are the
same code, and a shutdown now asks it to stop instead of killing it.

A new conformance case reads the registry's own listing and asks every
protocol it finds for its name, its version and its verb count, then offers
a verb nobody defines and requires -ENOSYS — the envelope's promise,
checked against providers rather than against itself. What it cannot reach
in that boot it names on the serial line instead of passing quietly.

Suite 110/110.
This commit is contained in:
Daniel Samson
2026-08-01 06:15:25 +01:00
parent b004b9c3eb
commit d2dfbcabf8
40 changed files with 1882 additions and 1008 deletions
+1 -1
View File
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
const exe = build_support.userBinary(b, .{
.name = "input",
.root_source_file = b.path("input.zig"),
.imports = &.{ "channel", "input-client", "input-protocol", "ipc", "logging", "process", "service" },
.imports = &.{ "envelope", "input-protocol", "ipc", "logging", "process", "service" },
});
b.installArtifact(exe);
}
+73 -69
View File
@@ -17,17 +17,29 @@
//!
//! A subscriber registers by handing the service its own endpoint as a capability (M13
//! capability passing — this service is its first real consumer). The service keeps that
//! handle and `ipc.send`s each event to it.
//! handle and `ipc.send`s each event to it. That is the envelope's reserved `subscribe`
//! verb, which this protocol adopts rather than defining its own.
//!
//! P4a moved this service onto the shared harness (library/kernel/service.zig). It was the
//! last hand-rolled receive loop in the tree, and the one service that answered neither the
//! universal ping nor a `terminate` signal — so a shutdown had to kill it. The subscriber
//! table, the fan-out, and the prune-on-subscribe below are unchanged; lifting *those* into
//! the harness is a later milestone, and doing it here would have hidden this one.
const std = @import("std");
const channel = @import("channel");
const envelope = @import("envelope");
const ipc = @import("ipc");
const process = @import("process");
const service = @import("service");
const input = @import("input-client");
const logging = @import("logging");
const input_protocol = @import("input-protocol");
/// The generated input dispatch. One fan-out point per process, so the handler
/// context is empty and the subscriber table stays in this file's globals.
const Serve = input_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// One registered subscriber: the endpoint we push events to (a capability it handed us at
/// subscribe time) and the task id that owns it (the subscribe call's badge), so a slot
/// left behind by a subscriber that exited can be reclaimed.
@@ -83,80 +95,72 @@ fn addSubscriber(endpoint: ipc.Handle, task_id: u32, device_mask: u32) bool {
/// Push `event` to every subscriber whose interest mask includes its device class.
/// `ipc.send` never blocks, so a slow or dead subscriber cannot stall delivery to others.
///
/// The class is the packet's operation, so the fan-out picks the event by device and the
/// packet is framed once, outside the loop — every subscriber of a class gets identical
/// bytes, which is what "one fan-out point per event domain" means on the wire.
fn broadcast(event: input_protocol.InputEvent) void {
const bytes = std.mem.asBytes(&event);
const class = input_protocol.eventOfDevice(event.device) orelse return; // no class wants it
var packet: [envelope.post_maximum]u8 = undefined;
const framed = switch (class) {
.keyboard => input_protocol.Protocol.encodeEvent(.keyboard, 0, event.asKeyboard() orelse return, &packet),
.mouse => input_protocol.Protocol.encodeEvent(.mouse, 0, event.asMouse() orelse return, &packet),
.joystick => input_protocol.Protocol.encodeEvent(.joystick, 0, event.asJoystick() orelse return, &packet),
} orelse return;
const bit = input_protocol.deviceBit(event.device);
for (&subscribers) |*sub| {
if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, bytes);
if (sub.used and sub.device_mask & bit != 0) _ = ipc.send(sub.endpoint, framed);
}
}
/// Handle one request. `got` carries the sender badge (a task id); `arrived` carries the
/// capability the request came with, under the same ownership rule the service harness
/// states (`ipc.Arrival`): **it belongs to the turn, and only a handler that means to keep
/// it says `take`.** Everything else here — a short message, a `publish`, a subscribe that
/// finds the table full — simply returns, and the loop closes what arrived. Writes a
/// `Reply` into `out` and returns its length.
fn handle(message: []const u8, got: ipc.Received, out: []u8, arrived: *ipc.Arrival) usize {
const reply = struct {
fn write(buffer: []u8, status: i32) usize {
const header = input_protocol.Reply{ .status = status };
@memcpy(buffer[0..input_protocol.reply_size], std.mem.asBytes(&header));
return input_protocol.reply_size;
}
};
/// Set by `onSubscribe` when the subscriber table has taken ownership of the capability the
/// call carried, and read by `onMessage`, which is where the turn's `Arrival` lives. The
/// generated dispatch hands a handler the raw handle rather than the `Arrival` — deliberately,
/// since a handler has no business closing the turn's property — so the *claim* has to travel
/// back out this way. One turn, one handler, one thread: there is nothing here to race.
var capability_claimed = false;
if (message.len < input_protocol.request_size) return reply.write(out, -1);
const request = std.mem.bytesToValue(input_protocol.Request, message[0..input_protocol.request_size]);
/// The reserved `subscribe` verb: register the caller's endpoint (the call's capability) for
/// the classes in the packet's tail. Refusals simply return, and the turn closes what arrived
/// — the ownership rule the harness states (`ipc.Arrival`), unchanged by the move onto it.
fn onSubscribe(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed
// A zero mask means "everything" (a subscriber that named no class still wants input).
const requested = input_protocol.decodeSubscribe(invocation.tail).device_mask;
const mask = if (requested == 0) input_protocol.device_all else requested;
pruneDeadSubscribers();
if (!addSubscriber(endpoint, invocation.sender, mask)) return -envelope.ENOSPC; // table full
capability_claimed = true; // the subscriber table holds it until that task dies
return 0;
}
switch (@as(input_protocol.Operation, @enumFromInt(request.operation))) {
.subscribe => {
const endpoint = arrived.peek() orelse return reply.write(out, -1); // no endpoint passed
// A zero mask means "everything" (a subscriber that named no class still wants input).
const mask = if (request.device_mask == 0) input_protocol.device_all else request.device_mask;
pruneDeadSubscribers();
if (!addSubscriber(endpoint, @intCast(got.badge), mask)) return reply.write(out, -1); // table full
_ = arrived.take(); // claimed: the subscriber table holds it until that task dies
return reply.write(out, 0);
},
.publish => {
broadcast(request.event);
return reply.write(out, 0);
},
}
fn onPublish(_: void, invocation: Invocation(input_protocol.InputEvent), _: Answer(void)) isize {
broadcast(invocation.request);
return 0;
}
const handlers = Serve.Handlers{ .publish = onPublish, .subscribe = onSubscribe };
fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
capability_claimed = false;
const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), out);
if (capability_claimed) _ = arrived.take();
return written;
}
fn initialise(_: ipc.Handle) bool {
// The harness has already bound `/protocol/input` by the time this runs, so
// "ready" still means what it always meant: the name is claimed and the loop
// is about to serve it.
_ = logging.write("/system/services/input: ready\n");
return true;
}
pub fn main() void {
const endpoint = ipc.createIpcEndpoint() orelse {
_ = logging.write("/system/services/input: no endpoint\n");
return;
};
if (!channel.bindPatiently("input", endpoint)) {
_ = logging.write("/system/services/input: could not bind /protocol/input\n");
return;
}
_ = logging.write("/system/services/input: ready\n");
var reply_buffer: [input_protocol.reply_size]u8 = undefined;
var reply_len: usize = 0;
var receive: [input_protocol.request_size]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
// Whatever capability came with this turn is the turn's, and the turn closes it
// unless `handle` claims it (`ipc.Arrival`). The kernel installs a sent capability
// whatever the message's length or kind, so this covers the notification
// `continue` and every refusal inside `handle` — otherwise about thirty-two
// capability-carrying calls, which need no authorization at all, exhaust this
// service's handle table and no further subscribe can ever land.
var arrived: ipc.Arrival = .{ .handle = got.cap };
defer arrived.release();
// Only synchronous client requests (subscribe/publish) arrive here; nothing sends
// this service asynchronous messages, so a notification wake would be spurious.
if (got.isNotification()) {
reply_len = 0;
continue;
}
reply_len = handle(receive[0..got.len], got, &reply_buffer, &arrived);
}
service.run(input_protocol.message_maximum, .{
.service = "input",
.init = initialise,
.on_message = onMessage,
});
}