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:
@@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "fat",
|
||||
.root_source_file = b.path("fat.zig"),
|
||||
.imports = &.{
|
||||
"block", "file-system", "ipc", "logging", "memory", "process", "service", "time",
|
||||
"vfs-protocol",
|
||||
"block", "envelope", "file-system", "ipc", "logging", "memory", "process",
|
||||
"service", "time", "vfs-protocol",
|
||||
},
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
+131
-99
@@ -20,8 +20,17 @@ const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const engine = @import("engine.zig");
|
||||
const on_disk = @import("on-disk.zig");
|
||||
const envelope = @import("envelope");
|
||||
const vfs_protocol = @import("vfs-protocol");
|
||||
|
||||
/// The generated vfs dispatch, bound to this server. There is one FAT volume per
|
||||
/// process, so the handler context is empty and the state stays where it was: in
|
||||
/// this file's globals.
|
||||
const Serve = vfs_protocol.Protocol.Provider(void);
|
||||
|
||||
const Invocation = envelope.Invocation;
|
||||
const Answer = envelope.Answer;
|
||||
|
||||
const mount_point = "/volumes/usb";
|
||||
|
||||
// The engine's BlockDevice, backed by the `.block` driver plus a DMA bounce
|
||||
@@ -75,16 +84,11 @@ fn openAt(id: u64) ?*OpenNode {
|
||||
return if (o.used) o else null;
|
||||
}
|
||||
|
||||
fn writeReply(out: []u8, reply: vfs_protocol.Reply, payload: []const u8) usize {
|
||||
@memcpy(out[0..vfs_protocol.reply_size], std.mem.asBytes(&reply));
|
||||
const n = @min(payload.len, out.len - vfs_protocol.reply_size);
|
||||
@memcpy(out[vfs_protocol.reply_size..][0..n], payload[0..n]);
|
||||
return vfs_protocol.reply_size + n;
|
||||
}
|
||||
|
||||
fn fail(out: []u8) usize {
|
||||
return writeReply(out, .{ .status = -1 }, &.{});
|
||||
}
|
||||
/// What a handler returns when the thing asked for is not there — a bad node id,
|
||||
/// a path that does not resolve, a mutation the volume refused. One errno for all
|
||||
/// of them, because a filesystem's failures are all "no such thing" as far as the
|
||||
/// file API can act on them.
|
||||
const refused: isize = -envelope.ENOENT;
|
||||
|
||||
/// How often to look for a block device while none is mounted. Storage arriving
|
||||
/// is EVENT-shaped (the usb chain registering, possibly after a driver restart),
|
||||
@@ -204,24 +208,128 @@ fn splitParent(path: []const u8) ParentLeaf {
|
||||
};
|
||||
}
|
||||
|
||||
fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize {
|
||||
fn onOpen(_: void, invocation: Invocation(vfs_protocol.Open), answer: Answer(vfs_protocol.Opened)) isize {
|
||||
const path = invocation.tail;
|
||||
const flags = invocation.request.flags;
|
||||
var node = filesystem.resolve(path);
|
||||
if (node == null and flags & vfs_protocol.create != 0) {
|
||||
const split = splitParent(path);
|
||||
const parent = filesystem.resolve(split.parent) orelse return fail(out);
|
||||
const parent = filesystem.resolve(split.parent) orelse return refused;
|
||||
node = filesystem.createFile(parent, split.leaf);
|
||||
}
|
||||
var resolved = node orelse return fail(out);
|
||||
var resolved = node orelse return refused;
|
||||
// O_TRUNC: replace an existing file's contents rather than overwriting in place
|
||||
// (frees the old chain, so a shorter rewrite leaves no stale tail).
|
||||
if (flags & vfs_protocol.truncate != 0 and !resolved.is_directory) {
|
||||
filesystem.truncate(&resolved);
|
||||
}
|
||||
const index = allocOpen() orelse return fail(out);
|
||||
open_nodes[index] = .{ .used = true, .node = resolved, .owner = sender };
|
||||
return writeReply(out, .{ .status = 0, .node = index }, &.{});
|
||||
const index = allocOpen() orelse return refused;
|
||||
open_nodes[index] = .{ .used = true, .node = resolved, .owner = invocation.sender };
|
||||
answer.set(.{ .node = index });
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onRead(_: void, invocation: Invocation(vfs_protocol.Read), answer: Answer(void)) isize {
|
||||
const o = openAt(invocation.target) orelse return refused;
|
||||
const into = answer.tail();
|
||||
const want = @min(@as(usize, invocation.request.len), into.len);
|
||||
return @intCast(filesystem.readFile(o.node, @intCast(invocation.request.offset), into[0..want]));
|
||||
}
|
||||
|
||||
fn onWrite(_: void, invocation: Invocation(vfs_protocol.Write), answer: Answer(vfs_protocol.Written)) isize {
|
||||
const o = openAt(invocation.target) orelse return refused;
|
||||
const data = invocation.tail[0..@min(invocation.tail.len, invocation.request.len)];
|
||||
const n = filesystem.writeFile(&o.node, @intCast(invocation.request.offset), data);
|
||||
answer.set(.{ .count = @intCast(n) });
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onStatus(_: void, invocation: Invocation(void), answer: Answer(vfs_protocol.FileStatus)) isize {
|
||||
const o = openAt(invocation.target) orelse return refused;
|
||||
const kind: vfs_protocol.NodeKind = if (o.node.is_directory) .directory else .regular;
|
||||
answer.set(.{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime });
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// One entry per call. End of directory — a node that is not a directory, or a
|
||||
/// cursor past the last child — is an entry with no name, which is how the
|
||||
/// protocol spells it now that the reply's length always counts the fixed part.
|
||||
fn onReaddir(_: void, invocation: Invocation(vfs_protocol.Readdir), answer: Answer(vfs_protocol.DirectoryEntry)) isize {
|
||||
const o = openAt(invocation.target) orelse return refused;
|
||||
if (!o.node.is_directory) {
|
||||
answer.set(.{});
|
||||
return 0;
|
||||
}
|
||||
const listing = filesystem.listEntry(o.node, @intCast(invocation.request.cursor)) orelse {
|
||||
answer.set(.{});
|
||||
return 0;
|
||||
};
|
||||
const kind: vfs_protocol.NodeKind = if (listing.is_directory) .directory else .regular;
|
||||
const into = answer.tail();
|
||||
const name_len = @min(listing.name_len, into.len);
|
||||
@memcpy(into[0..name_len], listing.name_buffer[0..name_len]);
|
||||
answer.set(.{ .kind = @intFromEnum(kind), .name_len = @intCast(name_len), .size = listing.size });
|
||||
return @intCast(name_len);
|
||||
}
|
||||
|
||||
fn onClose(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
if (openAt(invocation.target)) |o| o.used = false;
|
||||
// Durable-on-close: if any block reached the device since the last flush,
|
||||
// commit its cache to stable media now (best-effort). This is what makes
|
||||
// init's shutdown log flush survive a real power-off, and is the right
|
||||
// default for removable media the user may unplug.
|
||||
if (device_dirty) {
|
||||
_ = ipc_block.device.flush();
|
||||
device_dirty = false;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onMakeDirectory(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
const path = invocation.tail;
|
||||
if (filesystem.resolve(path) != null) return refused; // already exists — no duplicate entries
|
||||
const split = splitParent(path);
|
||||
const parent = filesystem.resolve(split.parent) orelse return refused;
|
||||
if (filesystem.createDirectory(parent, split.leaf) == null) return refused;
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onUnlink(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
const split = splitParent(invocation.tail);
|
||||
const parent = filesystem.resolve(split.parent) orelse return refused;
|
||||
if (!filesystem.removeFile(parent, split.leaf)) return refused;
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onRename(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
const both = invocation.tail;
|
||||
const separator = std.mem.indexOfScalar(u8, both, 0) orelse return refused;
|
||||
const old_split = splitParent(both[0..separator]);
|
||||
const new_split = splitParent(both[separator + 1 ..]);
|
||||
// Same-directory rename only.
|
||||
if (!std.mem.eql(u8, old_split.parent, new_split.parent)) return refused;
|
||||
const parent = filesystem.resolve(old_split.parent) orelse return refused;
|
||||
if (!filesystem.rename(parent, old_split.leaf, new_split.leaf)) return refused;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// The verbs this backend implements. The three it leaves out — `mount`,
|
||||
/// `unmount`, `bind` — answer `-ENOSYS` from the generated dispatch, which is
|
||||
/// exactly right: path routing is the kernel's now, and only init implements
|
||||
/// `bind` (docs/os-development/protocol-namespace.md). `describe` is the
|
||||
/// envelope's own.
|
||||
const handlers = Serve.Handlers{
|
||||
.open = onOpen,
|
||||
.close = onClose,
|
||||
.read = onRead,
|
||||
.write = onWrite,
|
||||
.status = onStatus,
|
||||
.readdir = onReaddir,
|
||||
.mkdir = onMakeDirectory,
|
||||
.unlink = onUnlink,
|
||||
.rename = onRename,
|
||||
};
|
||||
|
||||
/// The vfs protocol has no operation that takes a capability, so `arrived` is
|
||||
/// never claimed here — which, under the harness's ownership rule, means the
|
||||
/// loop closes whatever a caller attached. That is the point of the rule: this
|
||||
@@ -230,92 +338,16 @@ fn handleOpen(out: []u8, path: []const u8, flags: u32, sender: u32) usize {
|
||||
/// thirty-two until it could accept no capability at all.
|
||||
fn onMessage(message: []const u8, out: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
_ = arrived;
|
||||
if (!mounted) return fail(out); // storage not up (yet): fail politely, clients retry
|
||||
if (message.len < vfs_protocol.request_size) return fail(out);
|
||||
const request = std.mem.bytesToValue(vfs_protocol.Request, message[0..vfs_protocol.request_size]);
|
||||
const payload = message[vfs_protocol.request_size..];
|
||||
|
||||
// Storage not up (yet): fail politely, whatever was asked — clients retry.
|
||||
if (!mounted) {
|
||||
const status = envelope.Status{ .status = refused, .len = 0 };
|
||||
@memcpy(out[0..envelope.prefix_size], std.mem.asBytes(&status));
|
||||
return envelope.prefix_size;
|
||||
}
|
||||
// Stamp create/write with the current wall-clock time (mtime). Cheap, and it
|
||||
// keeps the engine pure (it takes the time as data, not a syscall).
|
||||
filesystem.current_time_epoch = time.wallClock();
|
||||
|
||||
switch (request.operation) {
|
||||
.open => return handleOpen(out, payload[0..@min(payload.len, request.len)], request.flags, sender),
|
||||
.read => {
|
||||
const o = openAt(request.node) orelse return fail(out);
|
||||
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
|
||||
const want = @min(@as(usize, request.len), buffer.len);
|
||||
const n = filesystem.readFile(o.node, @intCast(request.offset), buffer[0..want]);
|
||||
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, buffer[0..n]);
|
||||
},
|
||||
.write => {
|
||||
const o = openAt(request.node) orelse return fail(out);
|
||||
const data = payload[0..@min(payload.len, request.len)];
|
||||
const n = filesystem.writeFile(&o.node, @intCast(request.offset), data);
|
||||
return writeReply(out, .{ .status = 0, .len = @intCast(n) }, &.{});
|
||||
},
|
||||
.status => {
|
||||
const o = openAt(request.node) orelse return fail(out);
|
||||
const kind: vfs_protocol.NodeKind = if (o.node.is_directory) .directory else .regular;
|
||||
const status = vfs_protocol.FileStatus{ .size = o.node.size, .kind = @intFromEnum(kind), .mtime = o.node.mtime };
|
||||
return writeReply(out, .{ .status = 0, .len = @sizeOf(vfs_protocol.FileStatus) }, std.mem.asBytes(&status));
|
||||
},
|
||||
.readdir => {
|
||||
const o = openAt(request.node) orelse return fail(out);
|
||||
if (!o.node.is_directory) return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
|
||||
const listing = filesystem.listEntry(o.node, @intCast(request.offset)) orelse return writeReply(out, .{ .status = 0, .len = 0 }, &.{});
|
||||
const kind: vfs_protocol.NodeKind = if (listing.is_directory) .directory else .regular;
|
||||
const header = vfs_protocol.DirectoryEntry{ .kind = @intFromEnum(kind), .name_len = @intCast(listing.name_len), .size = listing.size };
|
||||
var buffer: [vfs_protocol.maximum_payload]u8 = undefined;
|
||||
@memcpy(buffer[0..vfs_protocol.directory_entry_size], std.mem.asBytes(&header));
|
||||
const nlen = @min(listing.name_len, buffer.len - vfs_protocol.directory_entry_size);
|
||||
@memcpy(buffer[vfs_protocol.directory_entry_size..][0..nlen], listing.name_buffer[0..nlen]);
|
||||
const total = vfs_protocol.directory_entry_size + nlen;
|
||||
return writeReply(out, .{ .status = 0, .len = @intCast(total) }, buffer[0..total]);
|
||||
},
|
||||
.close => {
|
||||
if (openAt(request.node)) |o| o.used = false;
|
||||
// Durable-on-close: if any block reached the device since the last
|
||||
// flush, commit its cache to stable media now (best-effort). This is
|
||||
// what makes init's shutdown log flush survive a real power-off, and is
|
||||
// the right default for removable media the user may unplug.
|
||||
if (device_dirty) {
|
||||
_ = ipc_block.device.flush();
|
||||
device_dirty = false;
|
||||
}
|
||||
return writeReply(out, .{ .status = 0 }, &.{});
|
||||
},
|
||||
.mkdir => {
|
||||
const path = payload[0..@min(payload.len, request.len)];
|
||||
if (filesystem.resolve(path) != null) return fail(out); // already exists — no duplicate entries
|
||||
const split = splitParent(path);
|
||||
const parent = filesystem.resolve(split.parent) orelse return fail(out);
|
||||
if (filesystem.createDirectory(parent, split.leaf) == null) return fail(out);
|
||||
return writeReply(out, .{ .status = 0 }, &.{});
|
||||
},
|
||||
.unlink => {
|
||||
const split = splitParent(payload[0..@min(payload.len, request.len)]);
|
||||
const parent = filesystem.resolve(split.parent) orelse return fail(out);
|
||||
if (!filesystem.removeFile(parent, split.leaf)) return fail(out);
|
||||
return writeReply(out, .{ .status = 0 }, &.{});
|
||||
},
|
||||
.rename => {
|
||||
const both = payload[0..@min(payload.len, request.len)];
|
||||
const sep = std.mem.indexOfScalar(u8, both, 0) orelse return fail(out);
|
||||
const old_split = splitParent(both[0..sep]);
|
||||
const new_split = splitParent(both[sep + 1 ..]);
|
||||
// Same-directory rename only.
|
||||
if (!std.mem.eql(u8, old_split.parent, new_split.parent)) return fail(out);
|
||||
const parent = filesystem.resolve(old_split.parent) orelse return fail(out);
|
||||
if (!filesystem.rename(parent, old_split.leaf, new_split.leaf)) return fail(out);
|
||||
return writeReply(out, .{ .status = 0 }, &.{});
|
||||
},
|
||||
// A backend is never itself a mount target.
|
||||
// Router verbs, and the registry's claim verb: a file backend answers
|
||||
// none of them (docs/os-development/protocol-namespace.md — only init
|
||||
// implements `bind`).
|
||||
.mount, .unmount, .bind => return fail(out),
|
||||
}
|
||||
return Serve.dispatch({}, handlers, message, sender, null, out);
|
||||
}
|
||||
|
||||
pub fn main() void {
|
||||
|
||||
Reference in New Issue
Block a user