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
+33 -24
View File
@@ -13,6 +13,7 @@ const memory = @import("memory");
const logging = @import("logging");
const compositor = @import("compositor.zig");
const envelope = @import("envelope");
const scanout_protocol = @import("scanout-protocol");
const Rect = compositor.Rect;
const Surface = compositor.Surface;
@@ -188,45 +189,53 @@ pub const VirtioGpu = struct {
/// accumulated; the driver transfers + fenced-flushes the whole frame.
pub fn present(self: *const VirtioGpu, damage: []const Rect) void {
_ = damage;
var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.present),
.width = self.width,
.height = self.height,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
_ = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch {};
_ = call(self.scanout, .present, .{ .width = self.width, .height = self.height });
}
/// Fill `out` with the driver's offered modes; returns how many were written.
pub fn modes(self: *const VirtioGpu, out: []Mode) usize {
var request = scanout_protocol.Request{ .operation = @intFromEnum(scanout_protocol.Operation.get_modes) };
var reply: [scanout_protocol.modes_reply_size]u8 = undefined;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return 0;
if (n < scanout_protocol.modes_reply_size) return 0;
const answer = std.mem.bytesToValue(scanout_protocol.ModesReply, reply[0..scanout_protocol.modes_reply_size]);
if (answer.status != 0) return 0;
const count = @min(@min(answer.count, scanout_protocol.max_modes), out.len);
for (0..count) |i| out[i] = answer.modes[i];
const answered = call(self.scanout, .get_modes, {}) orelse return 0;
const offered = Scanout.decodeReply(.get_modes, answered.packet[0..answered.len]) orelse return 0;
const count = @min(@min(offered.count, scanout_protocol.max_modes), out.len);
for (0..count) |i| out[i] = offered.modes[i];
return count;
}
/// Change the scanout resolution. On success the active `width`/`height` update (the shared
/// surface — sized to the max mode — is unchanged, so `stride` stays put).
pub fn setMode(self: *VirtioGpu, w: u32, h: u32) bool {
if (w == 0 or h == 0 or w > self.stride) return false;
var request = scanout_protocol.Request{
.operation = @intFromEnum(scanout_protocol.Operation.set_mode),
.width = w,
.height = h,
};
var reply: [scanout_protocol.reply_size]u8 = undefined;
const n = ipc.call(self.scanout, std.mem.asBytes(&request), &reply) catch return false;
if (n < scanout_protocol.reply_size) return false;
if (std.mem.bytesToValue(scanout_protocol.Reply, reply[0..scanout_protocol.reply_size]).status != 0) return false;
_ = call(self.scanout, .set_mode, .{ .width = w, .height = h }) orelse return false;
self.width = w;
self.height = h;
return true;
}
};
const Scanout = scanout_protocol.Protocol;
/// A reply the driver answered with, kept whole so the caller can decode the
/// verb's own fixed part out of it.
const Answered = struct {
packet: [scanout_protocol.message_maximum]u8,
len: usize,
};
/// One request at the scanout driver. Null covers both a transport failure and a
/// driver that refused — a present that did not happen is a present that did not
/// happen, and this backend has nothing to do about either but skip the frame.
fn call(
scanout: ipc.Handle,
comptime operation: Scanout.Operation,
request: Scanout.RequestOf(operation),
) ?Answered {
var packet: [scanout_protocol.message_maximum]u8 = undefined;
const framed = Scanout.encodeRequest(operation, 0, request, &.{}, &packet) orelse return null;
var answered: Answered = .{ .packet = undefined, .len = 0 };
answered.len = ipc.call(scanout, framed, &answered.packet) catch return null;
const status = envelope.statusOf(answered.packet[0..answered.len]) orelse return null;
if (status.status != 0) return null;
return answered;
}
/// The pluggable scanout backend. A tagged union so the compositor holds one value and
/// dispatches without caring which is active; the `virtio` native backend joins `gop` at V4.
pub const Backend = union(enum) {
+3 -2
View File
@@ -10,8 +10,9 @@ pub fn build(b: *std.Build) void {
.name = "display",
.root_source_file = b.path("display.zig"),
.imports = &.{
"channel", "display-client", "display-protocol", "driver", "input-client", "ipc",
"logging", "memory", "scanout-protocol", "service", "thread", "time",
"channel", "display-client", "display-protocol", "driver", "envelope", "input-client",
"ipc", "logging", "memory", "scanout-protocol", "service",
"thread", "time",
},
.threaded = true, // real atomics/TLS (docs/threading.md)
});
+121 -81
View File
@@ -28,10 +28,22 @@ const logging = @import("logging");
const compositor = @import("compositor.zig");
const backend_mod = @import("backend.zig");
const envelope = @import("envelope");
const display_protocol = @import("display-protocol");
const Rect = compositor.Rect;
const Surface = compositor.Surface;
/// The generated display dispatch. One compositor per process, so the handler
/// context is empty and the layer stack stays in this file's globals.
const Serve = display_protocol.Protocol.Provider(void);
const Invocation = envelope.Invocation;
const Answer = envelope.Answer;
/// What a handler returns when the layer named in `Header.target` is not one of
/// ours, or a mode was refused.
const refused: isize = -envelope.ENOENT;
/// The active scanout backend — the GOP framebuffer at boot, upgraded to a native driver
/// (virtio-gpu) when one announces itself (V4).
var backend: backend_mod.Backend = undefined;
@@ -314,11 +326,18 @@ fn verifyNativePresent() void {
/// `systemSharedMemoryMap`) — the pixels stay ours after the handle naming them
/// goes, and a driver that dies and re-announces no longer costs a handle slot
/// per restart.
fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz: u32, arrived: *ipc.Arrival, reply: []u8) usize {
const cap = arrived.peek() orelse return fail(reply);
if (width == 0 or height == 0 or stride < width) return fail(reply);
const mapped = memory.sharedMap(cap) orelse return fail(reply);
const scanout = channel.openEndpoint("scanout") orelse return fail(reply);
fn onAttachScanout(_: void, invocation: Invocation(display_protocol.AttachScanout), _: Answer(void)) isize {
const announce = invocation.request;
const stride = announce.stride;
const width = announce.width;
const height = announce.height;
const format = announce.format;
const refresh_hz = announce.refresh_hz;
const cap = invocation.capability orelse return refused;
if (width == 0 or height == 0 or stride < width) return refused;
const mapped = memory.sharedMap(cap) orelse return refused;
const scanout = channel.openEndpoint("scanout") orelse return refused;
// A second announce means the driver died and was restarted (V6): re-attach to its fresh
// scanout. (The previous shared mapping leaks — there is no shared_memory_unmap syscall yet — but the
// frames are the dead driver's, reclaimed on its exit; a handful across a crash is benign.)
@@ -346,7 +365,7 @@ fn attachScanout(stride: u32, width: u32, height: u32, format: u32, refresh_hz:
"display: scanout re-attached\n"
else
"display: scanout upgraded to virtio-gpu\n");
return ok(reply);
return 0;
}
/// After the native upgrade is verified, prove the runtime-resolution-change and fenced-present
@@ -609,88 +628,109 @@ fn initialise(endpoint: ipc.Handle) bool {
return true;
}
fn writeReply(reply: []u8, value: display_protocol.Reply) usize {
const bytes = std.mem.asBytes(&value);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
// --- the protocol handlers --------------------------------------------------
//
// A layer id is `Header.target` on every verb that names one, so no handler
// reads a layer out of its own request any more. `target` is a u64 and a layer
// id a u32: a value that does not fit is not a layer of ours, and `layerAt`
// refuses it the same way an out-of-range one is refused.
fn targetLayer(target: u64) ?u32 {
if (target > std.math.maxInt(u32)) return null;
return @intCast(target);
}
fn ok(reply: []u8) usize {
return writeReply(reply, .{ .status = 0 });
fn onInfo(_: void, _: Invocation(void), answer: Answer(display_protocol.Info)) isize {
const mode = backend.info();
answer.set(.{ .width = mode.width, .height = mode.height, .pitch = mode.pitch, .format = mode.format });
return 0;
}
fn fail(reply: []u8) usize {
return writeReply(reply, .{ .status = -1 });
fn onCreateLayer(_: void, invocation: Invocation(display_protocol.CreateLayer), answer: Answer(display_protocol.Created)) isize {
const request = invocation.request;
const slot = createLayer(request.x, request.y, request.width, request.height, request.z, request.visible != 0) orelse return refused;
answer.set(.{ .layer = slot });
return 0;
}
fn onConfigureLayer(_: void, invocation: Invocation(display_protocol.ConfigureLayer), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
return if (configureLayer(id, request.x, request.y, request.z, request.visible != 0)) 0 else refused;
}
fn onDestroyLayer(_: void, invocation: Invocation(void), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
return if (destroyLayer(id)) 0 else refused;
}
fn onFillRect(_: void, invocation: Invocation(display_protocol.FillRect), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
const local = Rect.init(request.x, request.y, @intCast(request.width), @intCast(request.height));
return if (fillLayer(id, local, request.colour)) 0 else refused;
}
fn onBlitTile(_: void, invocation: Invocation(display_protocol.BlitTile), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const request = invocation.request;
return if (blitLayer(id, request.x, request.y, request.width, request.height, invocation.tail)) 0 else refused;
}
fn onDamage(_: void, invocation: Invocation(display_protocol.Damage), _: Answer(void)) isize {
const id = targetLayer(invocation.target) orelse return refused;
const l = layerAt(id) orelse return refused;
const request = invocation.request;
const screen = Rect{ .x = l.x + request.x, .y = l.y + request.y, .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return 0;
}
fn onPresent(_: void, _: Invocation(void), _: Answer(void)) isize {
// Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return 0;
}
fn onSetMode(_: void, invocation: Invocation(display_protocol.SetMode), _: Answer(void)) isize {
if (!backend.setMode(invocation.request.width, invocation.request.height)) return refused;
addDamage(screenRect()); // repaint the whole screen at the new resolution
present();
return 0;
}
fn onGetModes(_: void, _: Invocation(void), answer: Answer(display_protocol.Modes)) isize {
var list: [4]backend_mod.Mode = undefined;
const count = backend.modes(&list);
var modes = display_protocol.Modes{ .count = @intCast(count) };
for (0..@min(count, display_protocol.max_modes)) |i| {
modes.modes[i] = .{ .width = list[i].width, .height = list[i].height };
}
answer.set(modes);
return 0;
}
const handlers = Serve.Handlers{
.info = onInfo,
.create_layer = onCreateLayer,
.configure_layer = onConfigureLayer,
.destroy_layer = onDestroyLayer,
.fill_rect = onFillRect,
.blit_tile = onBlitTile,
.damage = onDamage,
.present = onPresent,
.attach_scanout = onAttachScanout,
.set_mode = onSetMode,
.get_modes = onGetModes,
};
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
_ = sender;
if (message.len < display_protocol.request_size) return fail(reply);
const request = std.mem.bytesToValue(display_protocol.Request, message[0..display_protocol.request_size]);
const payload = message[display_protocol.request_size..];
// Switch on the raw operation value — an out-of-range one must fail cleanly, not
// panic an `@enumFromInt`.
switch (request.operation) {
@intFromEnum(display_protocol.Operation.info) => {
const m = backend.info();
return writeReply(reply, .{ .status = 0, .width = m.width, .height = m.height, .pitch = m.pitch, .format = m.format });
},
@intFromEnum(display_protocol.Operation.create_layer) => {
// x/y are signed coordinates carried in the u32 wire fields — reinterpret the
// bits (@bitCast), don't range-check (@intCast) which a negative would fail.
const slot = createLayer(@bitCast(request.x), @bitCast(request.y), request.width, request.height, request.z, request.visible != 0) orelse return fail(reply);
return writeReply(reply, .{ .status = 0, .layer = slot });
},
@intFromEnum(display_protocol.Operation.configure_layer) => {
return if (configureLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.z, request.visible != 0)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.destroy_layer) => {
return if (destroyLayer(request.layer)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.fill_rect) => {
const local = Rect.init(@bitCast(request.x), @bitCast(request.y), @intCast(request.width), @intCast(request.height));
return if (fillLayer(request.layer, local, request.colour)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.blit_tile) => {
return if (blitLayer(request.layer, @bitCast(request.x), @bitCast(request.y), request.width, request.height, payload)) ok(reply) else fail(reply);
},
@intFromEnum(display_protocol.Operation.damage) => {
const l = layerAt(request.layer) orelse return fail(reply);
const screen = Rect{ .x = l.x + @as(i32, @bitCast(request.x)), .y = l.y + @as(i32, @bitCast(request.y)), .w = @intCast(request.width), .h = @intCast(request.height) };
addDamage(screen.intersect(layerScreenRect(l)));
return ok(reply);
},
@intFromEnum(display_protocol.Operation.present) => {
// Scheduled, not immediate: the frame clock composites the accumulated damage
// at the next tick, so back-to-back client presents coalesce into one frame.
schedulePresent();
return ok(reply);
},
@intFromEnum(display_protocol.Operation.attach_scanout) => {
return attachScanout(request.x, request.width, request.height, request.colour, request.y, arrived, reply);
},
@intFromEnum(display_protocol.Operation.set_mode) => {
if (!backend.setMode(request.width, request.height)) return fail(reply);
addDamage(screenRect()); // repaint the whole screen at the new resolution
present();
return ok(reply);
},
@intFromEnum(display_protocol.Operation.get_modes) => {
var list: [4]backend_mod.Mode = undefined;
const count = backend.modes(&list);
var response = display_protocol.ModesReply{ .status = 0, .count = @intCast(count), .modes = undefined };
for (0..display_protocol.max_modes) |i| {
response.modes[i] = if (i < count)
.{ .width = list[i].width, .height = list[i].height }
else
.{ .width = 0, .height = 0 };
}
const bytes = std.mem.asBytes(&response);
@memcpy(reply[0..bytes.len], bytes);
return bytes.len;
},
else => return fail(reply),
}
// The one capability this service is ever handed is the scanout driver's
// shared surface, and `attachScanout` deliberately does not claim it (the
// mapping holds its own reference) — so the capability is peeked, never
// taken, and the turn closes it.
return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
}
/// Two notification sources reach the compositor, and one coalesced badge can carry