Compare commits
25
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a5146ec13 | ||
|
|
cb30faf15f | ||
|
|
36a7cc5fe9 | ||
|
|
b3011fbb40 | ||
|
|
1b1c587c14 | ||
|
|
776b04184e | ||
|
|
2719b93530 | ||
|
|
e250d8cd79 | ||
|
|
d2dfbcabf8 | ||
|
|
b004b9c3eb | ||
|
|
3e6e21bf0a | ||
|
|
0fbd2c8f12 | ||
|
|
73aea4582b | ||
|
|
1379b699f3 | ||
|
|
1ff0991452 | ||
|
|
ac01f627d1 | ||
|
|
f3bc23cb81 | ||
|
|
8d4a7cf240 | ||
|
|
c4f16a5448 | ||
|
|
e4da4e0610 | ||
|
|
9a3238025d | ||
|
|
c96ef87714 | ||
|
|
de9870175f | ||
|
|
be04ebe954 | ||
|
|
62d6a7a150 |
+19
-61
@@ -31,60 +31,20 @@ pub fn freestandingTarget(b: *std.Build) std.Build.ResolvedTarget {
|
||||
});
|
||||
}
|
||||
|
||||
/// Which library domain package exports each importable module — the one
|
||||
/// name -> home table. When a domain grows a module, it gets a row here; a
|
||||
/// binary naming a module whose home is missing from its own build.zig.zon
|
||||
/// fails loudly at dependency resolution.
|
||||
const ModuleHome = struct { name: []const u8, home: []const u8 };
|
||||
const module_homes = [_]ModuleHome{
|
||||
// library/kernel — the userspace private-ABI library, split by concern.
|
||||
.{ .name = "abi", .home = "kernel" },
|
||||
.{ .name = "system-call", .home = "kernel" },
|
||||
.{ .name = "ipc", .home = "kernel" },
|
||||
.{ .name = "time", .home = "kernel" },
|
||||
.{ .name = "thread", .home = "kernel" },
|
||||
.{ .name = "logging", .home = "kernel" },
|
||||
.{ .name = "process", .home = "kernel" },
|
||||
.{ .name = "file-system", .home = "kernel" },
|
||||
.{ .name = "memory", .home = "kernel" },
|
||||
.{ .name = "service", .home = "kernel" },
|
||||
.{ .name = "start", .home = "kernel" },
|
||||
// library/device — driver-side libraries + the flat reference data.
|
||||
.{ .name = "mmio", .home = "device" },
|
||||
.{ .name = "acpi-ids", .home = "device" },
|
||||
.{ .name = "device-abi", .home = "device" },
|
||||
.{ .name = "aml", .home = "device" },
|
||||
.{ .name = "usb-abi", .home = "device" },
|
||||
.{ .name = "usb-ids", .home = "device" },
|
||||
.{ .name = "usb", .home = "device" },
|
||||
.{ .name = "driver", .home = "device" },
|
||||
.{ .name = "block", .home = "device" },
|
||||
.{ .name = "pci", .home = "device" },
|
||||
.{ .name = "pci-class", .home = "device" },
|
||||
.{ .name = "device-registry", .home = "device" },
|
||||
// library/client — userspace service clients.
|
||||
.{ .name = "display-client", .home = "client" },
|
||||
.{ .name = "input-client", .home = "client" },
|
||||
// library/protocol — the wire protocols.
|
||||
.{ .name = "vfs-protocol", .home = "protocol" },
|
||||
.{ .name = "input-protocol", .home = "protocol" },
|
||||
.{ .name = "block-protocol", .home = "protocol" },
|
||||
.{ .name = "usb-transfer-protocol", .home = "protocol" },
|
||||
.{ .name = "device-manager-protocol", .home = "protocol" },
|
||||
.{ .name = "display-protocol", .home = "protocol" },
|
||||
.{ .name = "scanout-protocol", .home = "protocol" },
|
||||
.{ .name = "power-protocol", .home = "protocol" },
|
||||
// library/csv — the /etc/*.csv helpers.
|
||||
.{ .name = "csv", .home = "csv" },
|
||||
// library/xkeyboard-config — keycode -> keysym/character tables.
|
||||
.{ .name = "xkeyboard-config", .home = "xkeyboard-config" },
|
||||
};
|
||||
|
||||
fn moduleHome(name: []const u8) ?[]const u8 {
|
||||
for (module_homes) |entry| {
|
||||
if (std.mem.eql(u8, entry.name, name)) return entry.home;
|
||||
/// Resolve one imported module by searching the packages this binary DECLARED
|
||||
/// in its own build.zig.zon — the C include path made literal: an import can
|
||||
/// only be satisfied by a domain the binary claims, and each domain's own
|
||||
/// build.zig (its addModule exports) is the single statement of who owns
|
||||
/// what. There is no name table here to drift.
|
||||
fn moduleFromDeclaredDependencies(b: *std.Build, name: []const u8) *std.Build.Module {
|
||||
for (b.available_deps) |declared| {
|
||||
const dependency = b.dependency(declared[0], .{});
|
||||
if (dependency.builder.modules.get(name)) |module| return module;
|
||||
}
|
||||
return null;
|
||||
@panic(b.fmt(
|
||||
"no declared dependency exports a module named '{s}' — declare the domain that owns it in this package's build.zig.zon",
|
||||
.{name},
|
||||
));
|
||||
}
|
||||
|
||||
/// What `userBinary` needs to know about one user binary.
|
||||
@@ -95,8 +55,8 @@ pub const UserBinaryOptions = struct {
|
||||
root_source_file: std.Build.LazyPath,
|
||||
/// Exactly the modules the program's source @imports (directly or through
|
||||
/// its same-directory files) — no more, no less. Order is free; sorted
|
||||
/// reads best. An undeclared @import fails the compile; a declared name no
|
||||
/// domain exports fails the build graph with a pointer to module_homes.
|
||||
/// reads best. An undeclared @import fails the compile; a name no
|
||||
/// declared domain exports fails the build graph, naming the miss.
|
||||
imports: []const []const u8,
|
||||
/// Built multi-threaded (`single_threaded = false`) so real atomics/TLS
|
||||
/// work — required before a binary may call `Thread.spawn`
|
||||
@@ -121,12 +81,10 @@ pub fn userBinary(b: *std.Build, options: UserBinaryOptions) *std.Build.Step.Com
|
||||
const kernel = b.dependency("kernel", .{});
|
||||
var imports: std.ArrayListUnmanaged(std.Build.Module.Import) = .empty;
|
||||
for (options.imports) |name| {
|
||||
const home = moduleHome(name) orelse @panic(b.fmt(
|
||||
"no library domain exports a module named '{s}' — if a domain grew it, add its row to module_homes in build-support/build.zig",
|
||||
.{name},
|
||||
));
|
||||
const dependency = if (std.mem.eql(u8, home, "kernel")) kernel else b.dependency(home, .{});
|
||||
imports.append(b.allocator, .{ .name = name, .module = dependency.module(name) }) catch @panic("OOM");
|
||||
imports.append(b.allocator, .{
|
||||
.name = name,
|
||||
.module = moduleFromDeclaredDependencies(b, name),
|
||||
}) catch @panic("OOM");
|
||||
}
|
||||
// Settings (target, optimize, code model, ...) live on the root module
|
||||
// only; the program module inherits them.
|
||||
|
||||
@@ -96,6 +96,48 @@ fn addKernel(
|
||||
return exe;
|
||||
}
|
||||
|
||||
/// One row of the production ship table: which package, which of its
|
||||
/// artifacts, and the FHS boot path. For most binaries all three share one
|
||||
/// name; the helpers below make a row from just that name.
|
||||
const ShipRow = struct { path: []const u8, package: []const u8, artifact: []const u8 };
|
||||
|
||||
fn service(comptime name: []const u8) ShipRow {
|
||||
return .{ .path = "system/services/" ++ name, .package = name, .artifact = name };
|
||||
}
|
||||
fn driver(comptime name: []const u8) ShipRow {
|
||||
return .{ .path = "system/drivers/" ++ name, .package = name, .artifact = name };
|
||||
}
|
||||
/// An extra artifact of a multi-binary driver package (ps2-bus, usb-hid),
|
||||
/// bundled at its own flattened /system/drivers path.
|
||||
fn driverArtifact(comptime package: []const u8, comptime artifact: []const u8) ShipRow {
|
||||
return .{ .path = "system/drivers/" ++ artifact, .package = package, .artifact = artifact };
|
||||
}
|
||||
|
||||
/// The production ship table — what a plain `zig build` image contains,
|
||||
/// beyond the specials the build fn adds around it (init, discovery, the
|
||||
/// /system/configuration data files; the /test fixtures join only under
|
||||
/// -Dtest-case).
|
||||
/// Selecting what goes into a build = selecting rows: a package in no row is
|
||||
/// not just unshipped, its build file is never even loaded
|
||||
/// (docs/build-packages-plan.md).
|
||||
const production_ship = [_]ShipRow{
|
||||
service("fat"),
|
||||
service("display"),
|
||||
service("display-demo"),
|
||||
service("device-manager"),
|
||||
service("input"),
|
||||
service("logger"),
|
||||
driver("pci-bus"),
|
||||
driver("ps2-bus"),
|
||||
driverArtifact("ps2-bus", "ps2-keyboard"),
|
||||
driverArtifact("ps2-bus", "ps2-mouse"),
|
||||
driver("usb-xhci-bus"),
|
||||
driverArtifact("usb-hid", "usb-hid-keyboard"),
|
||||
driverArtifact("usb-hid", "usb-hid-mouse"),
|
||||
driver("usb-storage"),
|
||||
driver("virtio-gpu"),
|
||||
};
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
ensureZigVersion();
|
||||
|
||||
@@ -211,48 +253,27 @@ pub fn build(b: *std.Build) void {
|
||||
const exe = addKernel(b, kernel_target, optimize, kernel_modules, test_case, serial);
|
||||
const exe_serial = addKernel(b, kernel_target, optimize, kernel_modules, test_case, true);
|
||||
|
||||
// --- the user-space binaries, every one of them a package ---
|
||||
// Binary packages (docs/build-packages-plan.md, phase 2): each binary
|
||||
// builds itself against the domain packages via build-support's shared
|
||||
// recipe, started in ring 3 by the kernel's user-ELF loader like always;
|
||||
// the root build just takes artifacts for the boot image. init receives
|
||||
// the root's -Dserial as a dependency option (its liveness heartbeat is a
|
||||
// serial/test-build diagnostic the QEMU harness asserts on; a flashable
|
||||
// image leaves it out).
|
||||
const init_exe = b.dependency("init", .{ .serial = serial }).artifact("init");
|
||||
|
||||
// --- the rest of the boot tree: /system services and drivers ---
|
||||
// Each is built by the same user-binary recipe and laid out at its FHS path on
|
||||
// the boot volume (see `bundled` below). The EFI loader walks the tree at boot
|
||||
// and hands the kernel an in-RAM initial_ramdisk of it (system/initial-ramdisk.zig).
|
||||
// (The /test fixtures are lazy dependencies, resolved further down only
|
||||
// for a -Dtest-case build.)
|
||||
// The drivers, each directory its own package: the PS/2 bus family (bus +
|
||||
// keyboard + mouse from one package), the xHCI bus driver, the USB HID
|
||||
// class drivers, and USB mass storage. Their unit tests ride along.
|
||||
const ps2_bus_package = b.dependency("ps2-bus", .{});
|
||||
const ps2_bus_exe = ps2_bus_package.artifact("ps2-bus");
|
||||
const ps2_keyboard_exe = ps2_bus_package.artifact("ps2-keyboard");
|
||||
const ps2_mouse_exe = ps2_bus_package.artifact("ps2-mouse");
|
||||
const usb_xhci_bus_exe = b.dependency("usb-xhci-bus", .{}).artifact("usb-xhci-bus");
|
||||
const usb_hid_package = b.dependency("usb-hid", .{});
|
||||
const usb_hid_keyboard_exe = usb_hid_package.artifact("usb-hid-keyboard");
|
||||
const usb_hid_mouse_exe = usb_hid_package.artifact("usb-hid-mouse");
|
||||
const usb_storage_package = b.dependency("usb-storage", .{});
|
||||
const usb_storage_exe = usb_storage_package.artifact("usb-storage");
|
||||
// The FAT filesystem server and the display stack, each its own package
|
||||
// (fat's and display's unit tests ride along in their packages).
|
||||
const fat_package = b.dependency("fat", .{});
|
||||
const fat_exe = fat_package.artifact("fat");
|
||||
const display_package = b.dependency("display", .{});
|
||||
const display_exe = display_package.artifact("display");
|
||||
const display_demo_exe = b.dependency("display-demo", .{}).artifact("display-demo");
|
||||
const virtio_gpu_package = b.dependency("virtio-gpu", .{});
|
||||
const virtio_gpu_exe = virtio_gpu_package.artifact("virtio-gpu");
|
||||
// The first binary package (docs/build-packages-plan.md, phase 2): pci-bus
|
||||
// builds itself against the domain packages; the root build just takes the
|
||||
// artifact for the boot image.
|
||||
const pci_bus_exe = b.dependency("pci-bus", .{}).artifact("pci-bus");
|
||||
// --- what ships: the boot tree ---
|
||||
// Every user binary and its FHS home on the boot volume. There is no packed
|
||||
// ramdisk artifact any more: make-fat-image.py lays each binary out at its
|
||||
// path on the image, and the EFI loader walks /system and /test at boot and
|
||||
// builds the in-RAM initial_ramdisk table from the trees — the volume's file
|
||||
// structure is the single source of truth. Entry names (and hence argv[0] and
|
||||
// task names) are these paths with a leading slash.
|
||||
//
|
||||
// The uniform rows live in `production_ship` (the table above `build`);
|
||||
// spelled out here are only the genuinely non-uniform entries: init
|
||||
// (receives the root's -Dserial as a dependency option — its liveness
|
||||
// heartbeat is a serial/test-build diagnostic the QEMU harness asserts
|
||||
// on; a flashable image leaves it out), discovery (the -Ddiscovery pick),
|
||||
// and the /system/configuration data files. Each binary builds itself
|
||||
// against the domain packages via build-support's shared recipe; the root
|
||||
// just takes artifacts (docs/build-packages-plan.md).
|
||||
var bundled_list: std.ArrayListUnmanaged(images.BundledBinary) = .empty;
|
||||
bundled_list.append(b.allocator, .{
|
||||
.path = "system/services/init",
|
||||
.binary = b.dependency("init", .{ .serial = serial }).artifact("init").getEmittedBin(),
|
||||
}) catch @panic("OOM");
|
||||
// The discovery service: one swappable process per firmware
|
||||
// (docs/discovery.md), bundled under the neutral ramdisk name
|
||||
// "discovery" so the device manager never learns which firmware it is on.
|
||||
@@ -269,62 +290,43 @@ pub fn build(b: *std.Build) void {
|
||||
.acpi => (b.lazyDependency("acpi", .{}) orelse @panic("system/services/acpi is missing")).artifact("discovery"),
|
||||
.fdt => (b.lazyDependency("fdt", .{}) orelse @panic("system/services/fdt is missing")).artifact("discovery"),
|
||||
};
|
||||
const device_manager_exe = b.dependency("device-manager", .{}).artifact("device-manager");
|
||||
// The input service and its exercisers: the fan-out server, a hardware-free synthetic
|
||||
// source, and a subscriber that doubles as the `input` test's oracle. See docs/input.md.
|
||||
const input_exe = b.dependency("input", .{}).artifact("input");
|
||||
const logger_exe = b.dependency("logger", .{}).artifact("logger");
|
||||
|
||||
// Every user binary and its FHS home on the boot volume. There is no packed
|
||||
// ramdisk artifact any more: make-fat-image.py lays each binary out at this
|
||||
// path on the image, and the EFI loader walks /system and /test at boot and
|
||||
// builds the in-RAM initial_ramdisk table from the trees — the volume's file
|
||||
// structure is the single source of truth. Entry names (and hence argv[0] and
|
||||
// task names) are these paths with a leading slash. Test fixtures mirror their
|
||||
// repo home: test/system/services/<name> in the source tree IS the boot path.
|
||||
// init's boot service list is data (/etc/init.csv). -Ddiagnose selects the
|
||||
// variant that omits the display stack (so the kernel's boot transcript stays
|
||||
// on screen); both are bundled at the same /etc/init.csv path.
|
||||
const init_csv_source = if (diagnose) "etc/init-diagnose.csv" else "etc/init.csv";
|
||||
const production_bundled = [_]images.BundledBinary{
|
||||
.{ .path = "system/services/init", .binary = init_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/fat", .binary = fat_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/display", .binary = display_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/display-demo", .binary = display_demo_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/device-manager", .binary = device_manager_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/input", .binary = input_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/discovery", .binary = discovery_exe.getEmittedBin() },
|
||||
.{ .path = "system/services/logger", .binary = logger_exe.getEmittedBin() },
|
||||
// A data file, not a binary: the device registry the manager reads at boot.
|
||||
// Packing it under /etc makes the kernel auto-mount /etc as a read-only
|
||||
// initrd tree (system/kernel/vfs.zig setInitialRamdisk), so the manager can
|
||||
// fs.open("/etc/devices.csv") with no filesystem service running.
|
||||
.{ .path = "etc/devices.csv", .binary = b.path("etc/devices.csv") },
|
||||
// init's service list, likewise read from the kernel-served initrd /etc.
|
||||
.{ .path = "etc/init.csv", .binary = b.path(init_csv_source) },
|
||||
.{ .path = "system/drivers/ps2-bus", .binary = ps2_bus_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/ps2-keyboard", .binary = ps2_keyboard_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/ps2-mouse", .binary = ps2_mouse_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/usb-xhci-bus", .binary = usb_xhci_bus_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/usb-hid-keyboard", .binary = usb_hid_keyboard_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/usb-hid-mouse", .binary = usb_hid_mouse_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/usb-storage", .binary = usb_storage_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/virtio-gpu", .binary = virtio_gpu_exe.getEmittedBin() },
|
||||
.{ .path = "system/drivers/pci-bus", .binary = pci_bus_exe.getEmittedBin() },
|
||||
};
|
||||
bundled_list.append(b.allocator, .{
|
||||
.path = "system/services/discovery",
|
||||
.binary = discovery_exe.getEmittedBin(),
|
||||
}) catch @panic("OOM");
|
||||
// The ship table: every uniform row, one line each.
|
||||
for (production_ship) |row| {
|
||||
bundled_list.append(b.allocator, .{
|
||||
.path = row.path,
|
||||
.binary = b.dependency(row.package, .{}).artifact(row.artifact).getEmittedBin(),
|
||||
}) catch @panic("OOM");
|
||||
}
|
||||
// Data files, not binaries: packing them under /system/configuration rides
|
||||
// the kernel's read-only initrd mount of /system (system/kernel/vfs.zig
|
||||
// setInitialRamdisk) — the device manager reads its registry and init its
|
||||
// service list with no filesystem service running. -Ddiagnose selects the
|
||||
// init.csv variant that omits the display stack (so the kernel's boot
|
||||
// transcript stays on screen); both bundle at the same
|
||||
// /system/configuration/init.csv path.
|
||||
const init_csv_source = if (diagnose) "system/configuration/init-diagnose.csv" else "system/configuration/init.csv";
|
||||
bundled_list.append(b.allocator, .{ .path = "system/configuration/devices.csv", .binary = b.path("system/configuration/devices.csv") }) catch @panic("OOM");
|
||||
bundled_list.append(b.allocator, .{ .path = "system/configuration/init.csv", .binary = b.path(init_csv_source) }) catch @panic("OOM");
|
||||
// The protocol grants: who may claim which name under /protocol
|
||||
// (docs/os-development/protocol-namespace.md). init reads it beside init.csv,
|
||||
// out of the same read-only initrd, before it spawns anything — the registrar
|
||||
// has to know its policy before the first provider asks.
|
||||
bundled_list.append(b.allocator, .{ .path = "system/configuration/protocol.csv", .binary = b.path("system/configuration/protocol.csv") }) catch @panic("OOM");
|
||||
// A no-option build assumes neither -Dtest-case nor -Ddiagnose: it ships the
|
||||
// production set only. The userspace test fixtures under /test join in only
|
||||
// for a test build — which the QEMU harness signals by passing
|
||||
// -Dtest-case=<name> for every scenario, exactly when they must be on the
|
||||
// boot volume. They are LAZY dependencies: a plain build neither compiles
|
||||
// them nor loads their build files (docs/build-packages-plan.md). Fixture
|
||||
// packages are uniform — the dependency name, the artifact name, and the
|
||||
// boot path's leaf all match the directory — so a name is a whole entry.
|
||||
var bundled_list: std.ArrayListUnmanaged(images.BundledBinary) = .empty;
|
||||
bundled_list.appendSlice(b.allocator, &production_bundled) catch @panic("OOM");
|
||||
// boot volume. They are LAZY dependencies too. Fixture packages are
|
||||
// uniform — the dependency name, the artifact name, and the boot path's
|
||||
// leaf all match the directory — so a name is a whole entry.
|
||||
if (test_case != null) for ([_][]const u8{
|
||||
"vfs-test", // the user-space VFS round-trip client
|
||||
"fat-test",
|
||||
"badge-scope-test", // the guessable-id probe: a second process names the first's node and layer
|
||||
"shared-memory-server",
|
||||
"shared-memory-client",
|
||||
"crash-test", // hellos to the device manager, then faults — drives the crash-loop cap
|
||||
@@ -336,6 +338,10 @@ pub fn build(b: *std.Build) void {
|
||||
"args-echo",
|
||||
"process-test",
|
||||
"thread-test", // the multi-threaded fixture (its package sets .threaded)
|
||||
"user-memory-test", // aims deliberately bad user pointers at the checked copy layer
|
||||
"protocol-registry-test", // drives the registrar: ungranted bind, collision, restart
|
||||
"protocol-denied-test", // restriction stage one: an ungranted open answers as absence
|
||||
"protocol-conformance-test", // the reserved verbs, asked of every provider the boot bound
|
||||
}) |fixture| {
|
||||
const package = b.lazyDependency(fixture, .{}) orelse
|
||||
@panic("a test fixture package is missing under test/system/services");
|
||||
@@ -419,12 +425,12 @@ pub fn build(b: *std.Build) void {
|
||||
protocol_library,
|
||||
csv_library,
|
||||
xkeyboard_config_library,
|
||||
fat_package,
|
||||
display_package,
|
||||
ps2_bus_package,
|
||||
usb_hid_package,
|
||||
usb_storage_package,
|
||||
virtio_gpu_package,
|
||||
b.dependency("fat", .{}),
|
||||
b.dependency("display", .{}),
|
||||
b.dependency("ps2-bus", .{}),
|
||||
b.dependency("usb-hid", .{}),
|
||||
b.dependency("usb-storage", .{}),
|
||||
b.dependency("virtio-gpu", .{}),
|
||||
}) |package| {
|
||||
test_step.dependOn(&package.builder.top_level_steps.get("test").?.step);
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
.@"virtio-gpu" = .{ .path = "system/drivers/virtio-gpu" },
|
||||
.@"vfs-test" = .{ .path = "test/system/services/vfs-test", .lazy = true },
|
||||
.@"fat-test" = .{ .path = "test/system/services/fat-test", .lazy = true },
|
||||
.@"badge-scope-test" = .{ .path = "test/system/services/badge-scope-test", .lazy = true },
|
||||
.@"shared-memory-server" = .{ .path = "test/system/services/shared-memory-server", .lazy = true },
|
||||
.@"shared-memory-client" = .{ .path = "test/system/services/shared-memory-client", .lazy = true },
|
||||
.@"crash-test" = .{ .path = "test/system/services/crash-test", .lazy = true },
|
||||
@@ -74,6 +75,10 @@
|
||||
.@"args-echo" = .{ .path = "test/system/services/args-echo", .lazy = true },
|
||||
.@"process-test" = .{ .path = "test/system/services/process-test", .lazy = true },
|
||||
.@"thread-test" = .{ .path = "test/system/services/thread-test", .lazy = true },
|
||||
.@"user-memory-test" = .{ .path = "test/system/services/user-memory-test", .lazy = true },
|
||||
.@"protocol-registry-test" = .{ .path = "test/system/services/protocol-registry-test", .lazy = true },
|
||||
.@"protocol-denied-test" = .{ .path = "test/system/services/protocol-denied-test", .lazy = true },
|
||||
.@"protocol-conformance-test" = .{ .path = "test/system/services/protocol-conformance-test", .lazy = true },
|
||||
// See `zig fetch --save <url>` for a command-line interface for adding dependencies.
|
||||
//.example = .{
|
||||
// // When updating this field to a new URL, be sure to delete the corresponding
|
||||
|
||||
+1
-1
@@ -83,7 +83,7 @@ pub fn addImageSteps(b: *std.Build, options: Options) std.Build.LazyPath {
|
||||
// holding the EFI stub, the kernel, and the whole /system tree of user
|
||||
// binaries at their FHS paths. QEMU presents this image as a USB mass-storage
|
||||
// device the guest boots from (see run-x86-64 and the test harness), and the
|
||||
// danos fat driver mounts the same image at /mnt/usb.
|
||||
// danos fat driver mounts the same image at /volumes/usb.
|
||||
const fat_image = addBootImage(b, options.kernel.getEmittedBin(), options.efi.getEmittedBin(), manifest_file, capsule_img, options.bundled);
|
||||
const fat_image_install = b.addInstallFile(fat_image, "danos-usb.img");
|
||||
b.getInstallStep().dependOn(&fat_image_install.step);
|
||||
|
||||
+2
-2
@@ -42,8 +42,8 @@ pub fn addRunSteps(b: *std.Build, fat_image_serial: std.Build.LazyPath) void {
|
||||
const vars_out = vars_copy.addOutputFileArg("OVMF_VARS.4m.fd");
|
||||
|
||||
// Capture the guest's serial0 (danos's machine-readable log) to the qemu-test
|
||||
// scratch area — a dev/host artifact, kept out of the FHS boot volume we mount.
|
||||
// (/var/log/system is reserved for the kernel's own logging system later.) One
|
||||
// scratch area — a dev/host artifact, kept out of the boot volume we mount.
|
||||
// (/system/logs on the volume belongs to the guest's own logger.) One
|
||||
// timestamped file per run.
|
||||
const log_dir = b.fmt("{s}/qemu-test", .{b.install_path});
|
||||
const make_log_dir = b.addSystemCommand(&.{ "mkdir", "-p", log_dir });
|
||||
|
||||
+2
-2
@@ -208,7 +208,7 @@ the whole reason for the arrangement ([vision.md](vision.md)).
|
||||
danos is a **monorepo of sub-projects**. Each service or driver is a directory that is
|
||||
its own Zig module — it can hold as many files as it needs, and other sub-projects
|
||||
reach it *by module name*, never by a path into its files. The source tree deliberately
|
||||
**mirrors the runtime FHS** ([danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md)):
|
||||
**mirrors the runtime file-system hierarchy** ([file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)):
|
||||
what you see under `system/` in the source is what a running danos represents under
|
||||
`/system`.
|
||||
|
||||
@@ -216,7 +216,7 @@ what you see under `system/` in the source is what a running danos represents un
|
||||
name.** `system/services/init/` contains `init.zig` (its root), and produces a binary
|
||||
addressed as **`system/services/init`** — the repeated leaf resolves away:
|
||||
|
||||
| Source (root file) | Addressed as (module / binary / FHS path) |
|
||||
| Source (root file) | Addressed as (module / binary / hierarchy path) |
|
||||
|----------------------------------------|--------------------------------------------|
|
||||
| `system/services/init/init.zig` | `system/services/init` → `/system/services/init` |
|
||||
| `system/drivers/ps2-bus/ps2-bus.zig` | `system/drivers/ps2-bus` → `/system/drivers/ps2-bus` |
|
||||
|
||||
@@ -62,8 +62,10 @@ Rules:
|
||||
include list — and its zon names only the domains those modules come from
|
||||
(plus `build-support` and `kernel`, which is implicit in every binary: the
|
||||
root shim and user link script live there). Nothing is pre-wired: an
|
||||
undeclared `@import` is a compile error, and build-support's one
|
||||
module-to-domain table (`module_homes`) resolves each name. Availability
|
||||
undeclared `@import` is a compile error, and build-support resolves each
|
||||
name by searching the packages the zon declares — the domains' own
|
||||
addModule exports are the single statement of who owns what, with no name
|
||||
table anywhere to drift. Availability
|
||||
never meant bloat — Zig only compiles what a program actually imports — but
|
||||
exactness makes the declared interface honest and machine-checked.
|
||||
- **Modules export source, not artifacts** — each consumer compiles libraries
|
||||
@@ -132,9 +134,9 @@ rewritten against the package template.
|
||||
## Execution notes (the finished shape)
|
||||
|
||||
- The shared recipe lives in `build-support/build.zig`: `userBinary` (what
|
||||
every binary package calls, resolving each named import through the
|
||||
`module_homes` table) and `programModule` (for per-binary addOptions
|
||||
modules). The `start` root shim and `user.ld` are named through the kernel
|
||||
every binary package calls; each named import resolves by searching the
|
||||
packages the binary's zon declares) and `programModule` (for per-binary
|
||||
addOptions modules). The `start` root shim and `user.ld` are named through the kernel
|
||||
package (Dependency.path).
|
||||
- Adding a binary = adding a directory with source + a ~15-line build.zig +
|
||||
zon (copy any existing binary package, e.g.
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
# The C library compatibility layer
|
||||
|
||||
A design note and milestone plan for **libdanos-c** — the mini C library that lets
|
||||
`zig cc` cross-compile C programs for danos. It is milestone **P0** of
|
||||
[python-on-danos-milestones.md](python-on-danos-milestones.md), expanded here the
|
||||
way [character-devices-and-tty.md](character-devices-and-tty.md) expands P1.
|
||||
CPython is the driving consumer, but the layer is general: any portable C program
|
||||
within its surface should build.
|
||||
|
||||
## What it is — and the three things it is not
|
||||
|
||||
The deliverable is a **sysroot**: a set of C headers plus a static `libdanos-c.a`,
|
||||
handed to `zig cc -target x86_64-freestanding-none` via `-isystem` and linked into
|
||||
every C binary. Three explicit non-goals keep it small:
|
||||
|
||||
- **Not a musl port.** Whole-musl assumes Linux syscall semantics at its bottom
|
||||
(the door the Zig roadmap deferred, twice now). We *lift* musl's pure-computation
|
||||
source files and *write* a danos-native bottom — see the layer split below.
|
||||
- **Not full POSIX — *yet*.** Stage 1's surface is "what CPython's minimal
|
||||
configuration and ordinary portable C need" — roughly 100–150 functions — and
|
||||
at that stage absence is a *feature*: configure scripts probe and adapt, and a
|
||||
linker error is honest. But the end state is a **full C compatibility layer**
|
||||
(see "The road to full coverage" below); the absence table is a schedule of
|
||||
arrivals, not a wall.
|
||||
- **Not a second runtime.** The library is a thin C-ABI re-spelling of the same
|
||||
danos-native surface `runtime` already provides. It contains no policy of its
|
||||
own; when the Zig track's `runtime.os` seam is authored, the libc bottom
|
||||
re-targets it near-mechanically — the fourth appearance of the roadmap's "same
|
||||
surface" symmetry.
|
||||
|
||||
One scoping rule sits above all three — the **size doctrine**: this layer serves
|
||||
**applications only**. The kernel and the system services never link libdanos-c;
|
||||
they stay danos-native Zig over `runtime`, small and static, because leanness is
|
||||
an operating-system property. Applications have their own budget and may be as
|
||||
big as they need to be. The libc is how big software *lands on* danos, never how
|
||||
danos itself is built.
|
||||
|
||||
## The layer split: lift the mathematics, write the plumbing
|
||||
|
||||
The realization that makes 100–150 functions tractable: a libc is two very
|
||||
different kinds of code, and the hard kind is portable.
|
||||
|
||||
| Layer | Contents | Source |
|
||||
|-------|----------|--------|
|
||||
| **Pure computation** | `string.h`/`memcpy` family, all of libm, `strtod`/`dtoa`, `strtol`, `qsort`, `ctype` tables, `gmtime` calendar math, the `printf`/`scanf` engines, `setjmp` (a dozen instructions of x86-64 asm) | **Lift from musl**, vendored under `library/c/third-party/musl/` (MIT; files compile standalone) |
|
||||
| **OS plumbing** | fds (`open`/`read`/`write`/`close`/`lseek`/`stat`/`getcwd`/`chdir`/`isatty`), `mmap`/`munmap`, clocks, `exit`, `getenv`, `getentropy` | **Write in Zig**, exporting C ABI over the `runtime` syscall + VFS client surface |
|
||||
| **The middle** | `malloc` over danos `mmap` (simple free-list; CPython's arenas sit above), `FILE*` buffering, `errno` | **Write in Zig** (small, danos-shaped) |
|
||||
| **Entry** | `crt0`: the existing danos entry shim ([sysv.md](os-development/sysv.md)) bridged to C `main(argc, argv, envp)`, `environ` initialised, `exit` flushing stdio | **Write** |
|
||||
|
||||
Two liftings deserve their own line because getting them wrong is silent
|
||||
corruption rather than a linker error:
|
||||
|
||||
- **`strtod`/float formatting.** Python's float `repr` guarantees shortest
|
||||
round-trip; that property lives entirely in these routines. musl's are correct;
|
||||
an improvised one would be subtly wrong for years. Lift, never write.
|
||||
- **The stdio engines.** musl's `vfprintf`/`vfscanf` are self-contained around
|
||||
its `FILE` abstraction (function-pointer read/write slots), so the whole
|
||||
formatted-I/O engine lifts too — we implement only the fd-backed slots
|
||||
(`__stdio_write`-shaped) and the buffering glue.
|
||||
|
||||
## Header policy
|
||||
|
||||
Hand-write the headers as danos's own minimal set rather than importing musl's
|
||||
(musl's are entangled with Linux ABI details), borrowing declarations freely.
|
||||
Freestanding compiler headers (`stdint.h`, `stddef.h`, `stdarg.h`, `stdbool.h`,
|
||||
`float.h`, `limits.h`) come from clang via `zig cc` — do not duplicate them.
|
||||
`errno.h` values are the danos errno enum re-spelled with POSIX names; there is no
|
||||
Linux numbering to be compatible with, so the enum is the truth.
|
||||
|
||||
Deliberate absences, and their planned arrivals — this table is the
|
||||
compatibility matrix, and "the road to full coverage" below is the schedule
|
||||
that empties it:
|
||||
|
||||
| Absent | Arrives with |
|
||||
|--------|--------------|
|
||||
| `pthread.h` | the post-P5 pthread subset over `thread_spawn`/futex — but see the risk below |
|
||||
| real `signal.h` (beyond no-op `signal()`/`raise` stubs) | M17 signals-over-IPC in the libc |
|
||||
| `dlfcn.h` | [dynamic-libraries.md](dynamic-libraries.md) D1 |
|
||||
| `fork`/`exec*`/`wait*` | P5 exposes danos spawn as `posix_spawn`; `fork` itself never (see below) |
|
||||
| `socket.h` | a future networking track |
|
||||
| locale beyond `"C"` | stage 3 evaluation (CPython is UTF-8-mode happy without it) |
|
||||
| pipes (`pipe()`) | P5 process-control cluster |
|
||||
|
||||
## The road to full coverage
|
||||
|
||||
The layer grows in three stages; only stage 1 is a current milestone (P0), but
|
||||
the stages exist so stage-1 decisions never have to be unmade:
|
||||
|
||||
- **Stage 1 — CPython-minimal** (P0, the slicing below): ~100–150 functions,
|
||||
static-only, absences honest.
|
||||
- **Stage 2 — the danos-complete layer**: the full hosted C11 standard library,
|
||||
plus every POSIX facility danos semantics support, landing as its enabling
|
||||
milestone lands — pipes and `posix_spawn` at P5, real signals at M17, the
|
||||
pthread subset after P5, `dlfcn.h` at
|
||||
[dynamic-libraries](dynamic-libraries.md) D1, sockets with networking. Stage 2
|
||||
is not one milestone but the standing rule that **every system capability
|
||||
gets its C spelling when it ships**, so the matrix above drains as the OS
|
||||
grows.
|
||||
- **Stage 3 — ecosystem grade**: the point where "portable C program" generally
|
||||
means "builds on danos" (autotools-style probing included). Reaching it is
|
||||
mostly stage 2 compounding, plus the long tail (locale, wide-char,
|
||||
`fnmatch`/`glob`/`regex` — the last three lift from musl like the rest). At
|
||||
this stage, re-evaluate hand-grown-vs-musl-port once with real data; the
|
||||
standing recommendation remains danos-native — musl's bottom assumes Linux
|
||||
syscall semantics, and by stage 3 the danos bottom exists and is tested —
|
||||
with musl continuing as the quarry for computation code.
|
||||
|
||||
Two boundaries are permanent and worth stating at every stage: **`fork` never
|
||||
comes** — danos is a spawn-shaped OS, and `fork`'s address-space-duplication
|
||||
semantics are hostile to everything from capabilities to threads; software that
|
||||
hard-requires `fork` (not `posix_spawn`) stays off the platform. And the
|
||||
**public ABI stays the vDSO + IPC protocols** — a full libc is a compatibility
|
||||
*layer*, not a second stable system ABI.
|
||||
|
||||
## Milestone slicing
|
||||
|
||||
1. **sysroot-skeleton** — layout under `library/c/` (a build package:
|
||||
`include/`, Zig sources, vendored musl subtree); `crt0`; string/mem +
|
||||
`ctype` lifted; a `build.zig` step making C binaries first-class targets.
|
||||
*Test:* a C program using only computation links and runs in QEMU
|
||||
(`c-hello` printing via a raw `write` extern to `debug_write`).
|
||||
2. **fd-plumbing** — `errno`; open/read/write/close/lseek/stat/unlink/mkdir/
|
||||
rename over the `runtime` VFS client; `getcwd`/`chdir`/`getenv`/
|
||||
`getentropy` arriving as P1 lands them (stubbed truthfully until then:
|
||||
`getenv` empty, `getentropy` `ENOSYS`). *Test:* QEMU `c-file-io` — create,
|
||||
write, reopen, read back, stat size + mtime through FAT.
|
||||
3. **malloc** — free-list allocator over danos `mmap`; `calloc`/`realloc`/
|
||||
`free`; alignment guarantees documented. *Test:* host + QEMU allocator
|
||||
torture (interleaved sizes, realloc growth, alignment asserts).
|
||||
4. **stdio** — `FILE*`, buffering modes, the lifted printf/scanf engines wired
|
||||
to the fd slots; `snprintf` family; stdin/stdout/stderr over fd 0/1/2.
|
||||
*Test:* host round-trip suite for format engines (especially `%.17g`
|
||||
float round-trip); QEMU `c-stdio` cooked-line echo once P1's console exists.
|
||||
5. **mathematics-and-time** — libm lifted wholesale; `strtod`/`strtol`;
|
||||
`clock_gettime` (monotonic + realtime over `clock`/`wall_clock`);
|
||||
`gmtime`/`mktime`/`strftime` (UTC only — no timezone database);
|
||||
`setjmp`/`longjmp`; `qsort`/`bsearch`; `abort`/`assert`. *Test:* host
|
||||
`strtod`/`dtoa` vectors against known-hard cases; QEMU `c-time` sanity
|
||||
against the wall clock.
|
||||
|
||||
Slices 1, 3, 4-host, and 5-host have **no dependency on P1** and can start
|
||||
immediately; slice 2 and the QEMU halves interleave with P1 as it lands.
|
||||
|
||||
**Exit for the layer as a whole** (= P0's exit): `c-hello` and `c-file-io` green
|
||||
in the QEMU suite, and the host-side computation tests green — at which point P2
|
||||
(CPython configure) becomes the layer's real integration test.
|
||||
|
||||
## Testing strategy: two targets, on purpose
|
||||
|
||||
The computation layer is target-independent, so it is unit-tested **on the host**
|
||||
(built for the host triple, compared against the host libc's answers —
|
||||
thousands of cheap oracle checks for `strtod`, `printf`, libm edge cases). The
|
||||
plumbing layer only means anything **on danos**, so it is tested in the QEMU
|
||||
suite like every other subsystem. Keeping the split explicit stops the slow-QEMU
|
||||
suite from absorbing tests that a host `zig test` runs in milliseconds.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **CPython's configure may insist on pthreads.** WASI-class targets build
|
||||
threadless, but verify this *first* in P2 bring-up; the fallback is a
|
||||
truthfully-single-threaded `pthread.h` stub set (create returns `EAGAIN`,
|
||||
mutexes are no-ops — valid when only one thread can exist). Decide from
|
||||
evidence, not assumption.
|
||||
- **`long double` is x87 80-bit on x86-64.** musl's libm handles it, but keep
|
||||
CPython away from it (`configure` uses `double` throughout by default);
|
||||
don't hand-write anything touching x87.
|
||||
- **errno is a contract, not a convention.** The Zig plumbing must map every
|
||||
`runtime` error to a POSIX name consistently — CPython turns errno into
|
||||
exception types (`FileNotFoundError` is `ENOENT`). One table, tested.
|
||||
- **`malloc` alignment**: 16-byte minimum on x86-64 (SSE spills in
|
||||
compiled C). The free-list must guarantee it from day one; retrofitting
|
||||
alignment bugs out of an allocator is misery.
|
||||
- **Vendoring discipline.** The musl subtree is lift-only — never edited in
|
||||
place (patches live beside it if ever needed), pinned to one musl release,
|
||||
with the file list documented so a version bump is a re-copy, not an
|
||||
archaeology dig.
|
||||
- **stdio buffering vs. crashes.** Buffered stdout + a crashing program eats
|
||||
output — the classic debugging trap. `stderr` stays unbuffered (per C
|
||||
standard) and `exit`/`abort` flush; document that `_exit` does not.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- **Lift-from-musl for all pure computation** (vendored, pinned, unedited) rather
|
||||
than writing or porting whole-musl.
|
||||
- **Hand-written danos-native headers**; danos errno values are the numbering.
|
||||
- **`library/c/` as a build package** producing both the sysroot and the
|
||||
first-class C-binary build step.
|
||||
- The **deliberate-absence table** as the living compatibility matrix, drained
|
||||
by the three-stage road above — with exactly one permanent "never": `fork`.
|
||||
- **Full coverage as the end state** (stage 3), reached by the standing rule
|
||||
that every system capability ships with its C spelling — not by a musl port.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — this is P0.
|
||||
- [dynamic-libraries.md](dynamic-libraries.md) — ships in this sysroot
|
||||
(`dlfcn.h` + the loader) once its D1 lands.
|
||||
- [python-on-danos.md](python-on-danos.md) — the design note that scoped the
|
||||
layer.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — P1; supplies
|
||||
the console that makes stdio interactive.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — the `runtime.os` seam the
|
||||
plumbing layer will re-target when it exists.
|
||||
- [os-development/sysv.md](os-development/sysv.md) — the entry stack `crt0`
|
||||
bridges.
|
||||
@@ -0,0 +1,174 @@
|
||||
# Character devices, the console, and the tty question
|
||||
|
||||
A design note for the **stream** half of the device world. danos has block devices
|
||||
(the USB storage service behind the FAT mount) but no character devices — and three
|
||||
tracks now need them at once: the terminal application, Zig self-hosting Phase 1
|
||||
("wire fd 0/1/2 to a console byte stream"), and [Python on danos](python-on-danos.md)
|
||||
Phase 1. This note settles what a character device *is* on danos before any of those
|
||||
tracks build one.
|
||||
|
||||
## The Unix picture, briefly
|
||||
|
||||
Unix splits devices in two: **block devices** are seekable arrays of fixed-size
|
||||
sectors (disks); **character devices** are unseekable byte streams (keyboards,
|
||||
serial ports, terminals, `/dev/null`, entropy). A **tty** is the canonical
|
||||
character device — a byte stream plus a *line discipline* (echo, line buffering,
|
||||
erase handling, Ctrl-C-to-signal) that lives in the kernel. A **pty** is a pair of
|
||||
character devices (master/slave) that exists so a *userspace* program — a terminal
|
||||
emulator — can impersonate terminal hardware to the kernel's in-kernel line
|
||||
discipline.
|
||||
|
||||
The identification asked for and confirmed: yes, tty and pty are character
|
||||
devices in this taxonomy.
|
||||
|
||||
## The realization that shapes everything: danos already has the mechanism
|
||||
|
||||
A Unix character device is an in-kernel dispatch table: major/minor numbers route
|
||||
`read()`/`write()` to a driver. danos already has exactly that dispatch — the VFS:
|
||||
`fs_resolve` routes a path to a mounted backend service, and `Operation.mount`
|
||||
attaches a backend *endpoint* at a prefix. What is missing is not a device model;
|
||||
it is **one node kind with stream semantics**. And the protocol already reserved
|
||||
it: `NodeKind.character_device = 2` sits unimplemented in
|
||||
[vfs-protocol.zig](../library/protocol/vfs/vfs-protocol.zig), exactly like
|
||||
`symbolic_link`.
|
||||
|
||||
So the design is small:
|
||||
|
||||
**A character device on danos is a VFS node, served by an ordinary service over
|
||||
the existing VFS wire protocol, whose read/write have stream semantics.**
|
||||
|
||||
No device numbers, no `/dev` special casing, no new syscalls, no new protocol —
|
||||
a service is reachable at a path, clients open it with `runtime.fs` like any
|
||||
file, and the node kind says what it is. (Since the protocol namespace landed
|
||||
in design, that path is `/protocol/console` — a protocol node, see
|
||||
[os-development/protocol-namespace.md](os-development/protocol-namespace.md) —
|
||||
rather than a mounted device file; the stream semantics below are unchanged.)
|
||||
|
||||
### Stream semantics (the actual contract change)
|
||||
|
||||
For a node whose kind is `character_device`:
|
||||
|
||||
- **`offset` is ignored** on read and write; there is no seek position. (`lseek`,
|
||||
when the C layer exists, returns `ESPIPE`.)
|
||||
- **Reads block** until at least one byte is available, then return what is there —
|
||||
**short reads are normal**, not EOF. A zero-length read reply means the stream
|
||||
is closed (hangup), not end-of-file-at-size.
|
||||
- **`FileStatus.size` is 0** and means nothing; `mtime` may be 0.
|
||||
- Writes may be short if the service's buffer is full; the client loops as it
|
||||
already must for the 256-byte message cap.
|
||||
|
||||
This is a semantics note on existing operations, not a wire change — the `Request`
|
||||
and `Reply` structs are untouched. The one true protocol addition is a **`control`
|
||||
operation** (appended to `Operation`, values stable): a typed request the stream's
|
||||
service interprets. Deliberately *not* an `ioctl` grab-bag — the control payloads
|
||||
are enumerated per protocol, starting with the terminal set below.
|
||||
|
||||
## The first character device is a pseudo-device
|
||||
|
||||
The first device is deliberately **not hardware**: an in-memory **loopback** — a
|
||||
byte queue served over the stream contract, where bytes written to one end are
|
||||
read from the other. It is the reference implementation of the semantics above
|
||||
(blocking reads, short reads, hangup on close, the `control` round-trip), it
|
||||
tests deterministically with no QEMU serial scripting, and it keeps hardware off
|
||||
the critical path entirely. `null` and `zero` come along nearly for free as
|
||||
degenerate cases. This is a decision, not a convenience: the dead-COM1 boot bug
|
||||
on real hardware already proved serial cannot be assumed present or alive, so
|
||||
**nothing in this milestone writes to COM1**. (A serial-backed stream node can
|
||||
exist *later* as one more optional backend for headless debugging; it is on
|
||||
nobody's critical path.)
|
||||
|
||||
The loopback is also not throwaway — it is the seed of P5's `pipe()`, which is
|
||||
the same object with two fds.
|
||||
|
||||
## The console service
|
||||
|
||||
A `console` service owns the line discipline — **in userspace**, where a
|
||||
microkernel wants it, not in the kernel as Unix has it:
|
||||
|
||||
- **The discipline is a pure library first**: bytes and key events in, bytes
|
||||
out, no I/O of its own — developed and host-tested against in-memory buffers,
|
||||
then shared verbatim between the console and the future terminal application.
|
||||
- **Input**: subscribes to keyboard `InputEvent` IPC (the structured events that
|
||||
exist today) and cooks them into bytes. Cooked mode is the default: echo, line
|
||||
buffering, backspace/erase, so a line is delivered on Enter. Raw mode delivers
|
||||
bytes as they come (the REPL's line editor and any full-screen program need it).
|
||||
- **Output is a pluggable sink**, and the stream contract is independent of it:
|
||||
the bring-up sink is in-memory (readable back by tests, mirrored to the boot
|
||||
log), and the real one is the framebuffer text renderer when the display
|
||||
track's font work lands.
|
||||
- **Control set** (the `control` payloads): mode raw/cooked, echo on/off, and
|
||||
window-size query — the minimal termios. Ctrl-C-to-signal joins when M17
|
||||
signals-over-IPC lands; until then Ctrl-C is just a byte.
|
||||
- Mounts itself at `/device/console` as a `character_device` node.
|
||||
|
||||
**fd 0/1/2** then stop being special: spawn hands the child three open handles
|
||||
(console by default; anything else if the parent chooses), and `runtime`'s fd
|
||||
table maps 0/1/2 to them. `isatty` is simply "does `status` say
|
||||
`character_device`" — no side channel needed.
|
||||
|
||||
## The pty answer: there is no pty
|
||||
|
||||
The pty exists in Unix *because the line discipline is in the kernel* — userspace
|
||||
terminal emulators need a kernel gadget to impersonate hardware. On danos the
|
||||
terminal emulator is already a userspace server, so the pair collapses:
|
||||
|
||||
**The graphical terminal application serves the VFS stream protocol itself and
|
||||
hands its own endpoints to the children it spawns as their fd 0/1/2.**
|
||||
|
||||
The terminal *is* the console service for its children — same protocol, same
|
||||
control set, same line discipline code (shared as a library with the boot
|
||||
console). No master/slave device pair, no `/dev/pts`, no new kernel object. When
|
||||
CPython arrives, the libc's `isatty`/read/write see a character device and are
|
||||
none the wiser; when xonsh eventually wants job control, that lands as control
|
||||
messages + M17 signals, still with no pty object.
|
||||
|
||||
What this costs: programs that *specifically* manipulate Unix ptys
|
||||
(`os.openpty()`, `pexpect`-style tools) have no direct equivalent — the danos
|
||||
answer is "spawn the child yourself with your own stream endpoints," which is the
|
||||
same capability with less machinery. Accepted.
|
||||
|
||||
## Milestone slicing
|
||||
|
||||
1. **pseudo-devices** — VFS honors `character_device` semantics end to end;
|
||||
`Operation.control` added; the in-memory **loopback** (plus `null`/`zero`)
|
||||
as the first device. QEMU test: one client writes, another reads — open,
|
||||
offsetless read/write, blocking read, short read, hangup on close, control
|
||||
round-trip. No hardware anywhere.
|
||||
2. **console-service** — the line-discipline library (host-tested, pure) plus
|
||||
the console composing keyboard `InputEvent`s with an in-memory output sink;
|
||||
mounted at `/device/console`. QEMU test injects key events and reads cooked
|
||||
lines and raw bytes back through the sink.
|
||||
3. **fd-inheritance** — spawn passes 0/1/2 handles; `runtime` fd table; `isatty`
|
||||
via `status`; existing binaries' stdout migrates from `debug_write` to fd 1
|
||||
(the logger keeps its own path).
|
||||
4. **terminal-as-server** — deferred to the terminal application milestone
|
||||
(Python track P3): the terminal reuses the discipline library and serves its
|
||||
children directly.
|
||||
|
||||
Steps 1–3 are exactly the shared seam that Zig self-hosting Phase 1 and Python
|
||||
Phase 1 both list; neither track repeats them.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- **No pty object; the terminal serves its children directly** (the section
|
||||
above) — the load-bearing simplification.
|
||||
- **`control` as an enumerated, typed operation** rather than an ioctl-style
|
||||
opaque pass-through.
|
||||
- **Line discipline in userspace services** (console + terminal, shared library),
|
||||
never in the kernel.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos.md](python-on-danos.md) — consumes this as its Phase 1.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — ditto ("stdio as fds").
|
||||
- [file-system-development/vfs-protocol.md](file-system-development/vfs-protocol.md) —
|
||||
the wire protocol this note extends.
|
||||
- [file-system-development/file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)
|
||||
— the tree the console surfaces in.
|
||||
- [os-development/protocol-namespace.md](os-development/protocol-namespace.md) —
|
||||
supersedes this note's device-node naming: the console lands as a protocol
|
||||
(`/protocol/console`, a protocol node), not a `/dev`-style device file. The
|
||||
stream semantics designed here (line discipline, cooked/raw modes) carry over
|
||||
unchanged.
|
||||
- [device-driver-development/input.md](device-driver-development/input.md) — the
|
||||
`InputEvent` stream the console cooks.
|
||||
@@ -182,6 +182,26 @@ test for "is this an abbreviation I must expand" is simply: *is there a longer w
|
||||
is a clipped form of?* If yes, write the word. If it's an initialism standing in for a
|
||||
phrase, leave it.
|
||||
|
||||
## Kernel code touches user memory only through `user-memory`
|
||||
|
||||
A syscall argument is an attacker-controlled integer. Kernel code never
|
||||
dereferences one: every read of a process's memory goes through
|
||||
`copyFromUser` and every write through `copyToUser`
|
||||
(`system/kernel/user-memory.zig`), which walk that address space's page tables
|
||||
and move the bytes through the physmap, with the permissions ring 3 itself
|
||||
would face. A bad pointer then fails the call instead of faulting the kernel,
|
||||
and a struct pulled in once cannot change underneath the checks that follow it.
|
||||
|
||||
This is not a review convention — CR4.SMAP enforces it in hardware
|
||||
(`docs/os-development/smep-smap.md`), so a raw dereference of a user address is
|
||||
a #PF with a kernel instruction pointer the first time the QEMU suite reaches
|
||||
it. Which is also why **there is no `stac` in this tree, and never should be**:
|
||||
`stac` suspends exactly that enforcement, the copy layer needs no such window
|
||||
by construction, and a change that adds one has removed the guarantee rather
|
||||
than worked around a limitation. The same goes for the boot-time `clac` patch
|
||||
at the interrupt entry — it exists so that ring 3 cannot suspend SMAP either,
|
||||
by taking an interrupt with `EFLAGS.AC` set.
|
||||
|
||||
## Zen of Zig
|
||||
|
||||
* Communicate intent precisely.
|
||||
|
||||
@@ -64,19 +64,48 @@ restarted instance to rebuild exactly the same ids.
|
||||
|
||||
## The protocol
|
||||
|
||||
A `device-manager-protocol` module (the vfs-protocol pattern): extern-struct
|
||||
messages, a version in the handshake, reserved fields everywhere. The manager is a
|
||||
well-known endpoint (`ipc.register(.device_manager)`); the badge tells it who is
|
||||
A `device-manager-protocol` module, defined through the
|
||||
[envelope](../os-development/protocol-namespace.md): every packet — request,
|
||||
reply, and pushed event alike — begins with the folded `Header`, and **the device
|
||||
id is `Header.target`**, the manager's object addressing. The contract is bound at
|
||||
`/protocol/device-manager`; the kernel-stamped badge tells the manager who is
|
||||
talking; the same endpoint receives its children's exit notifications — one loop,
|
||||
one world.
|
||||
|
||||
| Direction | Message | Purpose |
|
||||
| Direction | Packet | Purpose |
|
||||
|---|---|---|
|
||||
| driver → manager | `hello { version, role, device_id }` | confirms the argv assignment, starts the deadline clock |
|
||||
| bus → manager | `child_added { parent, bus_address, identity, device_id, hid }` | one node the bus discovered |
|
||||
| driver → manager | `hello { role, version }` @ the assigned device | confirms the argv assignment, starts the deadline clock |
|
||||
| bus → manager | `child_added { parent, bus_address, identity, bus, vendor, device, subsystem, hid }` @ the registered device id | one node the bus discovered |
|
||||
| bus → manager | `child_removed { parent, bus_address }` | unplug, or the bus lost it |
|
||||
| app → manager | `enumerate` | snapshot of the tree (read-only) |
|
||||
| app → manager | `subscribe` | receive published add/remove events |
|
||||
| app → manager | `enumerate` (reserved verb 1) | snapshot of the tree: one `ChildEntry` per record in the reply's tail |
|
||||
| app → manager | `subscribe` (reserved verb 2) | receive published add/remove events; the subscriber's endpoint rides as the call's capability |
|
||||
| manager → app | `child_added` / `child_removed` events | the same two structs, pushed rather than called |
|
||||
|
||||
The watcher table behind those last two rows is the **service harness's**
|
||||
(`service.Subscribers`, shared with input and power), not the manager's: it
|
||||
answers `subscribe`/`unsubscribe`, frames each event once for the fan-out, and
|
||||
sweeps a watcher on its exit notification — where the manager previously had no
|
||||
sweep for watchers at all. Its own supervised-driver exits are a different thing
|
||||
and unchanged, except that a driver's death now arrives twice (the manager is
|
||||
both its supervisor and a subscriber to published exits), so the manager retires
|
||||
a dead driver's process id as it handles the first and the second finds nothing
|
||||
to act on.
|
||||
|
||||
Two of what used to be the manager's own operations are the envelope's **reserved**
|
||||
verbs, which mean the same thing at every provider in the system, so this protocol
|
||||
numbers only three of its own (`hello` = 16, `child_added` = 17,
|
||||
`child_removed` = 18) and its two events in their own space (`child_added` = 16,
|
||||
`child_removed` = 17). No reply carries a status field: that is the `Status` every
|
||||
reply begins with.
|
||||
|
||||
`child_added` is the one struct that travels both ways — a bus *calls* it, the
|
||||
manager *pushes* it — which is why the operation and event numbering spaces are
|
||||
separate: one encoding, both directions, told apart by which way the packet went.
|
||||
Folding the operation byte and the device id out of it is also what makes it fit:
|
||||
a pushed event is 64 bytes at most, header included, and this one lands exactly on
|
||||
that floor. `child_removed` is the single message whose target stays 0, because it
|
||||
is addressed by the composite (parent, bus address) and no single `u64` carries a
|
||||
pair.
|
||||
|
||||
`hello` is the one deadline the manager enforces itself: spawned and silent past the
|
||||
deadline means wrong binary, wrong protocol version, or wedged before main — apply
|
||||
|
||||
@@ -87,13 +87,13 @@ rest of the system hasn't had to face:
|
||||
│ (ResourceKind.memory = [base, height*pitch], write-combining hint,
|
||||
│ plus DisplayInfo{width, height, pitch, format, refresh_hz})
|
||||
▼
|
||||
display service (system/services/display/, ServiceId.display) ← the compositor
|
||||
display service (system/services/display/, /protocol/display) ← the compositor
|
||||
│ device.claim(display node) → mmio_map(WRITE-COMBINING) = FRONT buffer (the LFB)
|
||||
│ mmap(cacheable) a BACK buffer of the same geometry
|
||||
│ owns: an ordered LAYER STACK + a per-frame DAMAGE tracker (rect list or tile grid)
|
||||
│ loop: composite dirty layers → back buffer → present dirty rects → front
|
||||
│ backend is an INTERNAL interface: {gop-fb} at boot; {virtio-gpu} on hot-attach (v2)
|
||||
▼ reached by name (ipc_lookup); clients drive it over the display protocol
|
||||
▼ reached by name (open /protocol/display); clients drive it over the display protocol
|
||||
┌────────────────────────────────────┬──────────────────────────────────────┐
|
||||
drawing clients (v1) surface clients (deferred)
|
||||
display commands: display surfaces:
|
||||
@@ -106,8 +106,10 @@ The bring-up sequence mirrors a hardware driver's — it is the
|
||||
[`usb-xhci-bus` `initialise`](../../system/drivers/usb-xhci-bus/usb-xhci-bus.zig) shape
|
||||
(claim → `mmio_map` → run loop) — and the request/reply service shell is the
|
||||
[FAT](../../system/services/fat/fat.zig) / [input](../../system/services/input/input.zig) shape
|
||||
([`service.run`](../../library/kernel/service.zig) with a `protocol.zig` of
|
||||
`extern struct` messages and an `Operation` tag).
|
||||
([`service.run`](../../library/kernel/service.zig) over the dispatch table its
|
||||
protocol module generates through
|
||||
[`envelope.Define`](../os-development/protocol-namespace.md) — one request and
|
||||
reply type per verb, and the layer id in the packet header's `target`).
|
||||
|
||||
**One process, for now.** v1 is a *single* service that both owns the framebuffer and
|
||||
composites — it does not split a "framebuffer driver" from a "compositor" the way input
|
||||
@@ -209,6 +211,18 @@ shell, a terminal, a cursor, and a wallpaper:
|
||||
| `damage` | mark a region of a layer dirty |
|
||||
| `present` | request a repaint: composited at the next frame-clock tick |
|
||||
|
||||
**A layer belongs to the client that created it.** The id is a slot in a
|
||||
sixteen-entry table — small, dense, guessable — so every verb above that names one is
|
||||
answered only for the task whose `create_layer` produced it, and a layer that is
|
||||
somebody else's is refused exactly as one that never existed (`-ENOENT`), so a client
|
||||
cannot use the refusal to learn which ids are live
|
||||
([protocol-namespace.md](../os-development/protocol-namespace.md): handles are scoped
|
||||
per client, validated against the badge). The compositor's own layers — the cursor
|
||||
sprite and the startup self-check's pair — are marked service-owned and are created by
|
||||
direct call rather than over the protocol, so no client can move or destroy the
|
||||
cursor. A dead client's layers are released on its exit notification, the same sweep
|
||||
the FAT server runs for open files.
|
||||
|
||||
Text is intentionally *not* an operation — a client renders glyphs by blitting tiles
|
||||
(the [PSF font](../../system/kernel/font.psf) path the console already uses can move into a
|
||||
client). Keeping the protocol to rectangles and tiles keeps the compositor small and the
|
||||
|
||||
@@ -22,12 +22,22 @@ event:
|
||||
- `JoystickEvent` — `axis` moves (a signed value on a `control` index) and
|
||||
`button_down`/`button_up`.
|
||||
|
||||
All three travel in one **`InputEvent` envelope** tagged with a `DeviceKind`, so the
|
||||
fan-out is a single code path and a subscriber can take a mix of classes on one stream.
|
||||
Decode an envelope with `asKeyboard()` / `asMouse()` / `asJoystick()` (each returns null
|
||||
unless the tag matches). A subscriber names the classes it wants with a **`device_mask`**,
|
||||
and the service routes each event only to subscribers whose mask includes its class — so a
|
||||
mouse-only listener never wakes for keystrokes.
|
||||
A source publishes any of the three as one **`InputEvent`** tagged with a `DeviceKind`, so
|
||||
`publish` is a single verb; decode one with `asKeyboard()` / `asMouse()` / `asJoystick()`
|
||||
(each returns null unless the tag matches). On the *delivery* wire the class is the
|
||||
packet's own operation instead — the protocol declares one event per class
|
||||
([protocol-namespace.md](../os-development/protocol-namespace.md)), so a pushed packet is
|
||||
the 16-byte header plus the typed event and nothing carries a tag twice. The client
|
||||
helpers re-tag what arrives back into an `InputEvent`, so a subscriber can still take a
|
||||
mix of classes on one stream. A subscriber names the classes it wants with a
|
||||
**`device_mask`**, and the service routes each event only to subscribers whose mask
|
||||
includes its class — so a mouse-only listener never wakes for keystrokes.
|
||||
|
||||
**`subscribe` is not this protocol's verb.** Its shape — a synchronous call whose attached
|
||||
capability is the subscriber's own endpoint — is what the envelope's *reserved* subscribe
|
||||
means at every provider in the system, so the input protocol adopts it rather than
|
||||
defining a second spelling of the same thing. The interest mask rides as the packet's
|
||||
tail. `publish` is the one verb the protocol defines for itself.
|
||||
|
||||
## Why this needed a new kernel primitive
|
||||
|
||||
@@ -98,12 +108,25 @@ This is the async counterpart of `ipc_call`, and the input service is its first
|
||||
`publishJoystickEvent`. Publishing is a short synchronous `ipc_call` the service answers at
|
||||
once; the service's own fan-out is asynchronous, so publishing never blocks on a slow
|
||||
subscriber.
|
||||
- The **service** ([input.zig](../../system/services/input/input.zig)) keeps a small subscriber
|
||||
table (endpoint handle + owning task id + `device_mask`). On `publish` it `ipc_send`s the
|
||||
event to every subscriber whose mask includes the event's device class. On `subscribe` it
|
||||
stores the passed capability and mask and, as housekeeping, prunes any slot whose owning
|
||||
process has exited (checked against `process_enumerate`) — not for correctness (an async
|
||||
send to an orphaned endpoint is harmless) but to reclaim the slot.
|
||||
- The **service** ([input.zig](../../system/services/input/input.zig)) owns none of that
|
||||
machinery any more: the subscriber table (endpoint handle + owning task + interest mask),
|
||||
the reserved `subscribe`/`unsubscribe` verbs, the fan-out, and the dead-subscriber sweep
|
||||
are the shared harness's (`service.Subscribers` in
|
||||
[service.zig](../../library/kernel/service.zig)), so every event stream in the system has
|
||||
identical semantics. What is left in this file is what is actually about input: which
|
||||
class an event belongs to, and which classes a subscriber asked for. On `publish` it names
|
||||
the event's class and the harness `ipc_send`s the packet — framed once — to every
|
||||
subscriber whose mask includes it.
|
||||
- **A dead subscriber goes away on its exit notification**, not on a poll. The service used
|
||||
to walk `process_enumerate` on every subscribe and drop slots whose owner had gone; it now
|
||||
subscribes to the kernel's published exits like the FAT server and the compositor do
|
||||
([process-lifecycle.md](../os-development/process-lifecycle.md)), which reclaims the slot
|
||||
*and* closes the endpoint capability in it promptly rather than at the next subscribe.
|
||||
(The fan-out also drops a subscriber whose `ipc_send` fails, as a backstop for a
|
||||
notification a full ring dropped.)
|
||||
- The service runs on the shared harness like every other, so it answers the universal ping
|
||||
and exits on `terminate`; it was the last hand-rolled receive loop in the tree, and the
|
||||
last service a shutdown had to kill rather than ask.
|
||||
|
||||
Publisher and subscriber must be **separate processes**: a single thread that both
|
||||
published and serviced its own subscription would deadlock (its `publish` call blocks until
|
||||
|
||||
@@ -1,34 +1,70 @@
|
||||
# IPC: message-passing channels
|
||||
# IPC: the kernel-ipc transport
|
||||
|
||||
Inter-process communication is the **backbone of a microkernel**. Once drivers and
|
||||
services run isolated in their own address spaces ([vision](../vision.md)), they can't
|
||||
just call each other — a request becomes a **message**. In a microkernel, whatever
|
||||
just call each other — a request becomes bytes on a wire. In a microkernel, whatever
|
||||
was a function call across a monolithic kernel is IPC, so it's a first-class
|
||||
concern, not an afterthought.
|
||||
|
||||
There are two layers, built a milestone apart:
|
||||
This document describes **one transport** — the bottom layer (L0) of the
|
||||
communication stack defined in
|
||||
[communication.md](../os-development/communication.md), which owns the model
|
||||
and the vocabulary (*protocol*, *channel*, *packet*, *signal*, *endpoint*).
|
||||
kernel-ipc is the **first** transport, not the only possible one: in
|
||||
buffer-plus-doorbell terms it is a kernel-owned mailbox with the scheduler as
|
||||
the doorbell. Its distinguishing properties, which the layers above may rely
|
||||
on where they say so:
|
||||
|
||||
- **`system/kernel/ipc.zig`** — a bounded blocking channel between *kernel threads*,
|
||||
described below. The primitive, and where the blocking discipline was worked out.
|
||||
- **`system/kernel/ipc-synchronous.zig`** — synchronous call/reply between *processes*, across
|
||||
address spaces. What user-space servers and drivers actually talk over. It's the
|
||||
second half of this document.
|
||||
- **Rendezvous.** A call is a synchronous meeting, copied sender-page to
|
||||
receiver-page — natural backpressure, no queue to size.
|
||||
- **Capability carriage.** The *only* transport that can move a handle
|
||||
between processes. Channels are therefore always established over
|
||||
kernel-ipc, and it remains every channel's control path even when bulk
|
||||
data is negotiated onto a fatter transport (a shared-memory ring).
|
||||
- **Verified source.** Every delivery carries the kernel-stamped badge — the
|
||||
identity the channel layer attaches to received packets.
|
||||
- **Bounded packets.** 256 bytes call/reply, 64 pushed — the floor every
|
||||
protocol may assume on any transport.
|
||||
|
||||
## The channel
|
||||
Three properties keep the networking analogy honest — kernel-ipc is
|
||||
networking-*shaped*, not TCP:
|
||||
|
||||
The first form is a **bounded blocking channel** (`system/kernel/ipc.zig`): a fixed-size
|
||||
ring buffer of messages with a producer/consumer rendezvous, built on the
|
||||
scheduler's [wait queues](../os-development/scheduling.md).
|
||||
- **Channels over it are RPC-shaped, not streams.** Packets, call/reply,
|
||||
datagram pushes — closer to UDP plus RPC than to a byte stream. Ordering
|
||||
exists per exchange (a reply answers its call), not across a channel.
|
||||
- **Possession is the connection.** There is no handshake state in the
|
||||
kernel: holding the capability *is* having the channel. A provider's one
|
||||
endpoint terminates every client's channel at once, demultiplexed by badge
|
||||
— like every client sharing the server's listening socket, with
|
||||
per-connection state living in the provider, keyed by badge. A *private*
|
||||
channel (a dedicated endpoint pair) is built when wanted: that is exactly
|
||||
what `subscribe` does.
|
||||
- **Packets never fragment.** If it doesn't fit in a packet, it isn't a
|
||||
packet: bulk data lives in shared memory and a packet (or signal) is the
|
||||
doorbell. The display path already works this way.
|
||||
|
||||
The rest of this document is the implementation, bottom-up: the kernel-thread
|
||||
queue the blocking discipline was worked out on, then endpoints — this
|
||||
transport's termination points.
|
||||
|
||||
## The kernel-thread queue
|
||||
|
||||
The first form is a **bounded blocking queue** (`system/kernel/ipc.zig`): a
|
||||
fixed-size ring buffer of messages with a producer/consumer rendezvous, built
|
||||
on the scheduler's [wait queues](../os-development/scheduling.md). (Its type
|
||||
is still named `Channel(T, capacity)` — it predates the vocabulary above, and
|
||||
is a *queue between kernel threads in one address space*, not a channel in
|
||||
the model's sense; a rename can ride a later flag-day.)
|
||||
|
||||
`Channel(T, capacity)` is generic over the message type and buffer size. It holds a
|
||||
ring buffer, a count, and two wait queues:
|
||||
|
||||
- **`send(msg)`** — if the channel is full, block on the *not-full* queue; otherwise
|
||||
- **`send(msg)`** — if the queue is full, block on the *not-full* queue; otherwise
|
||||
write the message, bump the count, and wake a waiting receiver.
|
||||
- **`receive()`** — if the channel is empty, block on the *not-empty* queue; otherwise
|
||||
- **`receive()`** — if the queue is empty, block on the *not-empty* queue; otherwise
|
||||
take a message, drop the count, and wake a waiting sender.
|
||||
|
||||
Neither side busy-waits: a full channel parks the sender, an empty one parks the
|
||||
Neither side busy-waits: a full queue parks the sender, an empty one parks the
|
||||
receiver, and each operation wakes the other side when it makes progress possible.
|
||||
|
||||
Two details make it correct:
|
||||
@@ -45,47 +81,53 @@ Two details make it correct:
|
||||
CPU. `waitLocked` / `wakeLocked` are the variants that assume the caller already
|
||||
holds that critical section.
|
||||
|
||||
## Verifying it
|
||||
### Verifying it
|
||||
|
||||
The `ipc` test (see [testing.md](../testing.md)) runs a producer and a consumer passing
|
||||
**100 messages through a 4-slot channel**. The small buffer means the channel goes
|
||||
**100 messages through a 4-slot queue**. The small buffer means the queue goes
|
||||
full and empty over and over, so both the blocking-send and blocking-receive paths are
|
||||
exercised heavily. The messages arrive intact and in order (their sum is the
|
||||
expected `5050`), and neither task busy-waits — they block and wake each other.
|
||||
|
||||
## Endpoints: call/reply across address spaces
|
||||
## Endpoints: the termination points
|
||||
|
||||
A channel connects two kernel threads sharing one address space. Real servers are
|
||||
*processes*, so the payload has to cross an address-space boundary. That's
|
||||
A queue connects two kernel threads sharing one address space. Real providers are
|
||||
*processes*, so a packet has to cross an address-space boundary. That's
|
||||
`system/kernel/ipc-synchronous.zig`, and its shape is L4's: a synchronous **rendezvous** at an
|
||||
`Endpoint`, with the message copied directly from the sender's pages to the receiver's
|
||||
`Endpoint`, with the packet copied directly from the sender's pages to the receiver's
|
||||
(`copyAcross` walks both sets of page tables through the physmap — no CR3 switch, no
|
||||
bounce buffer).
|
||||
|
||||
Two syscalls carry it:
|
||||
Two syscalls carry the request/reply exchange:
|
||||
|
||||
- **`ipc_call(h, msg, reply)`** — copy `msg` to the server, block until it replies.
|
||||
- **`ipc_call(h, msg, reply)`** — copy the request packet to the provider, block
|
||||
until the reply packet comes back.
|
||||
- **`ipc_reply_wait(h, reply, recv)`** — reply to the client you're still holding (if
|
||||
any), then block for the next request. One syscall, because a server's steady state
|
||||
any), then block for the next request. One syscall, because a provider's steady state
|
||||
is *always* "finish the last one, wait for the next".
|
||||
|
||||
An endpoint is reached by **handle** — a small integer index into the process's handle
|
||||
table (`Task.handles`), exactly like a file descriptor, and just as unforgeable. The
|
||||
bootstrap problem (how do you get the first handle?) is solved by a tiny name registry:
|
||||
a server calls `ipc_register(service_id, h)` under a well-known small integer, and a
|
||||
client calls `ipc_lookup(service_id)`.
|
||||
table (`Task.handles`), exactly like a file descriptor, and just as unforgeable.
|
||||
The provider never learns the client's identity beyond the **badge** delivered
|
||||
alongside each packet: the caller's task id, stamped by the kernel —
|
||||
unforgeable source addressing, a property a network's source field lacks.
|
||||
|
||||
The server never learns the client's identity beyond a **badge**, delivered alongside
|
||||
the message: the caller's task id.
|
||||
The bootstrap problem — how a channel is first established — is the subject of
|
||||
[protocol-namespace.md](../os-development/protocol-namespace.md): a protocol is
|
||||
resolved by name and the channel arrives as a capability. (The mechanism it
|
||||
replaced — `ipc_register`/`ipc_lookup` under compile-time `ServiceId` integers —
|
||||
is gone: both syscalls and the enum were deleted when the registry landed, and
|
||||
their syscall numbers are left vacant.)
|
||||
|
||||
### Interrupts are messages too
|
||||
### Interrupts are signals
|
||||
|
||||
`notifyFromIsr` posts an *asynchronous* notification to an endpoint — no payload, no
|
||||
`notifyFromIsr` posts an *asynchronous* signal to an endpoint — no payload, no
|
||||
reply owed — and wakes whoever is blocked in `reply_wait`. Its badge has the top bit
|
||||
set (`notify_badge_bit`), which is how a driver's single event loop distinguishes "a
|
||||
client wants something" from "the hardware wants something". Notifications sit in a
|
||||
client wants something" from "the hardware wants something". Signals sit in a
|
||||
small coalescing ring on the endpoint, so an interrupt taken while the driver was busy
|
||||
elsewhere is not lost.
|
||||
elsewhere is not lost — coalesced, never dropped, which is exactly a signal's
|
||||
contract (the *count* may collapse; the *fact* may not).
|
||||
|
||||
This is what makes a user-space driver possible at all, and it's the subject of
|
||||
[drivers.md](drivers.md).
|
||||
@@ -93,40 +135,65 @@ This is what makes a user-space driver possible at all, and it's the subject of
|
||||
## What's next (partly done since)
|
||||
|
||||
- **Priority inheritance** through IPC — still open: a high-priority client
|
||||
blocked on a low-priority server suffers unbounded priority inversion.
|
||||
blocked on a low-priority provider suffers unbounded priority inversion.
|
||||
- **Handle transfer.** *Landed as cap-passing (M13)*: `ipc_call` and
|
||||
`ipc_reply_wait` carry an optional capability alongside the bytes (`send_cap`),
|
||||
copying an endpoint or shared-memory handle into the peer's table. First user:
|
||||
[input](input.md) subscribers register by handing over their own endpoint, and
|
||||
class drivers get a private channel to one device.
|
||||
copying an endpoint or shared-memory handle into the peer's table — the
|
||||
mechanism by which channels are established and private channels built. First
|
||||
user: [input](input.md) subscribers register by handing over their own
|
||||
endpoint, and class drivers get a private channel to one device.
|
||||
- **Asynchronous / buffered send** for the cases where a rendezvous is the wrong
|
||||
shape (logging, notifications between servers). *Landed as `ipc_send`* — a
|
||||
non-blocking post to an endpoint's bounded payload queue, delivered through
|
||||
`reply_wait` as a buffered message (badge bit `notify_message_bit`). Built for, and
|
||||
first used by, the [input service](input.md)'s keyboard-event broadcast, where a
|
||||
synchronous push would let one dead subscriber hang the fan-out. A full queue drops
|
||||
the oldest (discrete messages, not a coalescing level like the notification ring).
|
||||
- **A bounded reply** — half landed. The copy is still 256 bytes
|
||||
(`MESSAGE_MAXIMUM`) under the big kernel lock, but bulk transfer got its shared
|
||||
shape (logging, event fan-out). *Landed as `ipc_send`* — a
|
||||
non-blocking post of an event packet (≤ 64 bytes) to an endpoint's bounded
|
||||
queue, delivered through `reply_wait` (badge bit `notify_message_bit`). Built
|
||||
for, and first used by, the [input service](input.md)'s keyboard-event
|
||||
broadcast, where a synchronous push would let one dead subscriber hang the
|
||||
fan-out. A full queue drops the oldest — event packets are droppable by
|
||||
design ([protocol-namespace.md](../os-development/protocol-namespace.md)'s
|
||||
wiring section states the rule).
|
||||
- **A bounded reply** — half landed. The copy is still one packet
|
||||
(256 bytes) under the big kernel lock, but bulk transfer got its shared
|
||||
pages: `shared_memory_create`/`map`/`physical`, the region handle delegated as
|
||||
a capability (above). virtio-gpu's scanout surface is the first user
|
||||
a capability (above) — the packets-never-fragment rule in practice.
|
||||
virtio-gpu's scanout surface is the first user
|
||||
([display-v2.md](display-v2.md)).
|
||||
|
||||
## Lifecycle conventions over IPC (M17)
|
||||
|
||||
Three conventions from [process-lifecycle.md](../os-development/process-lifecycle.md) ride the
|
||||
notification mechanism:
|
||||
signal mechanism:
|
||||
|
||||
- **Signals** arrive as notifications on the endpoint a process nominated with
|
||||
`signal_bind` (`process.bindSignals`): badge = the signal bit plus the
|
||||
coalesced pending mask (`process.signalsFrom` decodes). Statements,
|
||||
- **Process signals** arrive as endpoint signals on the endpoint a process
|
||||
nominated with `signal_bind` (`process.bindSignals`): badge = the signal bit
|
||||
plus the coalesced pending mask (`process.signalsFrom` decodes). Statements,
|
||||
never questions; no payload, no reply.
|
||||
- **One-shot timers** (`timer_bind`, `time.timerOnce`) land as a
|
||||
timer-bit notification — the timed wait: a service arms a deadline and keeps
|
||||
timer-bit signal — the timed wait: a service arms a deadline and keeps
|
||||
serving, instead of blocking in sleep.
|
||||
- **Kernel notifications go only to your own endpoint.** `signal_bind`,
|
||||
`timer_bind`, `process_subscribe`, `irq_bind`, `msi_bind`, and spawn's exit
|
||||
endpoint all *nominate where the kernel will speak*, and all of them refuse an
|
||||
endpoint the caller did not create (`-EPERM`; the check is `ipc.ownedBy`,
|
||||
normalized to the process, so any thread may nominate an endpoint a sibling
|
||||
created). Holding a handle is not enough, because holding a handle is cheap:
|
||||
`fs_resolve` installs a mounted backend's capability in *any* caller's table,
|
||||
so every process holds a handle to PID 1's mailbox. Without the rule, "bind
|
||||
init's endpoint, then signal yourself" is a genuine, kernel-stamped `terminate`
|
||||
badge in PID 1's queue — a shutdown a receiver has no way to disbelieve — and
|
||||
timers, which carry no identity at all, multiply any loop that re-arms on its
|
||||
own landing.
|
||||
- **A capability that arrives belongs to the turn.** The kernel installs a sent
|
||||
capability in the receiver's table whatever the message's length or kind, so a
|
||||
receive loop must dispose of one on *every* path — the ping, the notification,
|
||||
the malformed request. The service harness (`service.run`) and PID 1 both hold
|
||||
it in an `ipc.Arrival`, released by a `defer`, and a handler that means to keep
|
||||
it says `take()`: forgetting closes, keeping is explicit. The reverse
|
||||
arrangement leaks a handle-table slot per request, and thirty-two unauthorized
|
||||
zero-length pings then end a service's ability to accept any capability —
|
||||
no subscribe, no shared-memory handover — for the rest of the boot.
|
||||
- **The universal ping**: a **zero-length request is the liveness probe**,
|
||||
answered with a zero-length reply by the service harness itself
|
||||
(`service.run`). No protocol's requests start at length zero, so the
|
||||
encoding cannot collide, and a wedged service simply fails to answer — which
|
||||
is the diagnosis. Deep health ("can I reach my hardware?") stays a per-service
|
||||
protocol message.
|
||||
protocol packet.
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Dynamic libraries on danos
|
||||
|
||||
A design note and milestone plan for shared objects: building them, loading them
|
||||
with `dlopen`, and — the part that needs kernel work — actually *sharing* them
|
||||
between processes. Directional, post-P5 of
|
||||
[python-on-danos-milestones.md](python-on-danos-milestones.md); nothing on the
|
||||
CPython bring-up path depends on it.
|
||||
|
||||
## Reconciling the earlier "rejected"
|
||||
|
||||
Dynamic libraries were evaluated once before and rejected — but as an answer to a
|
||||
*different question*: whether they could claw back ReleaseSafe's measured ~2×
|
||||
code size. They cannot (the safety checks inline at every call site; no library
|
||||
scheme dedups them), and that verdict stands for that question. The reasons to
|
||||
build them now are the ones that investigation never weighed:
|
||||
|
||||
- **`ctypes` and runtime FFI** — Python calling into a danos library without
|
||||
rebuilding the interpreter. This is the piece that makes Python prototyping
|
||||
self-serve: drop a `.so` on the image, `ctypes.CDLL` it, iterate.
|
||||
- **Loadable CPython extension modules** — today every C extension means
|
||||
relinking the interpreter (`Modules/Setup`); with `dlopen`, an extension is a
|
||||
file.
|
||||
- **One interpreter image, many Python services** — a statically-linked CPython
|
||||
is tens of megabytes *per process*. A shared `libpython` mapped read-only once
|
||||
(milestone D3 below) makes Python services cheap enough to be the default way
|
||||
to prototype one.
|
||||
- **Plugin-shaped applications** — the UI toolkit and the terminal will want
|
||||
them eventually.
|
||||
|
||||
The scoping that dissolves the apparent contradiction is the **size doctrine**:
|
||||
leanness is an *operating-system* property — the kernel and system services stay
|
||||
small and statically linked, and none of them ever link the loader — while
|
||||
*applications* have their own budget and may be big. Dynamic libraries are an
|
||||
**application-layer facility**, full stop.
|
||||
|
||||
What also does **not** change: the public ABI stays the vDSO + the IPC
|
||||
protocols. Shared objects are artifacts *within* one system image, versioned by
|
||||
the build — not a new stable ABI surface for the OS.
|
||||
|
||||
## Design
|
||||
|
||||
- **Format and codegen are free.** ELF shared objects with position-independent
|
||||
code; `zig cc -fPIC -shared` against the [libdanos-c](c-library-compatibility.md)
|
||||
sysroot already emits them. The work is entirely on the loading side.
|
||||
- **The loader lives in userspace, inside the libc.** `dlopen` reads the `.so`
|
||||
through the VFS, maps its segments, applies relocations, resolves symbols
|
||||
against the process and the `DT_NEEDED` dependency graph, runs constructors,
|
||||
returns a handle. No kernel loader changes in v1 — segments land in anonymous
|
||||
`mmap` as private copies.
|
||||
- **Bind-now, always.** All relocations resolved at `dlopen` time
|
||||
(`RTLD_NOW` semantics only). Lazy PLT binding buys startup latency danos does
|
||||
not care about, at the price of a writable GOT dance and a much subtler
|
||||
loader. Not worth it; keep it out permanently.
|
||||
- **W^X from day one.** Map, relocate, then flip text pages read-execute —
|
||||
which requires memory-protection change (`mprotect`-shaped) in the danos
|
||||
`mmap` surface if it is not already there. No page is ever writable and
|
||||
executable at once.
|
||||
- **TLS in shared objects is deferred.** Thread-local storage models
|
||||
(initial-exec vs. general-dynamic) are the deep end of every dynamic linker.
|
||||
v1 refuses a `.so` with a TLS segment; revisit alongside the post-P5 pthread
|
||||
subset, which is when it could matter.
|
||||
- **Executables stay static until D4.** v1 is "a static binary that can
|
||||
`dlopen`" — no `PT_INTERP`, no program interpreter, no dynamically-linked
|
||||
`main` binaries. That keeps process startup untouched.
|
||||
|
||||
## Milestones
|
||||
|
||||
1. **D1 — dlopen in-process.** The `.so` build target; the loader in libdanos-c:
|
||||
map, relocate (`RELATIVE`/`GLOB_DAT`/`JUMP_SLOT`), resolve, constructors;
|
||||
`dlopen`/`dlsym`/`dlerror`/`dlclose`; private anonymous mappings; no TLS.
|
||||
*Test:* QEMU `dlopen-hello` — load a `.so`, call a symbol, unload, reload.
|
||||
2. **D2 — the FFI payoff.** `DT_NEEDED` dependency graphs; a **libffi port**
|
||||
(x86-64 SysV assembly is upstream; the port is its closure-allocation paths,
|
||||
which must respect W^X); CPython's `ctypes` enabled; extension modules
|
||||
loadable from file. *Test:* QEMU — a Python script `ctypes.CDLL`s a danos
|
||||
`.so` and round-trips a call; a `.so` extension module imports.
|
||||
3. **D3 — actual sharing (the kernel milestone).** Shared read-only file-backed
|
||||
mappings — a page-cache-shaped facility so N processes mapping `libpython`
|
||||
hold one physical copy. This is the memory-win milestone and the only one
|
||||
touching the kernel; design it with the existing shm machinery in view
|
||||
(the shared-fate walks already locked the relevant paths). *Test:* N Python
|
||||
services up; measure physical pages against N× the static baseline.
|
||||
4. **D4 — dynamically-linked executables** (optional, evaluate after D3):
|
||||
`PT_INTERP`, a danos program interpreter, and the spawn path teaching the
|
||||
loader about it. Only worth it if the image-size or update story demands it.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **Scope creep is the failure mode.** Every dynamic linker grows toward glibc.
|
||||
The fences: bind-now only, no lazy binding ever, no TLS until pthreads demand
|
||||
it, no dlopen-from-memory, no versioned symbols. Each fence removed is a
|
||||
design discussion, not a patch.
|
||||
- **Code loading is a security event.** `dlopen` turns file bytes into executable
|
||||
code, so W^X discipline is table stakes and *what may be dlopened* is a
|
||||
capability question — the natural danos answer is that loadability follows VFS
|
||||
readability of the `.so`, and services' images are supervised like any other
|
||||
artifact. Revisit explicitly at D3 when mappings become shared.
|
||||
- **`dlclose` is where loaders go to die.** Constructors/destructors,
|
||||
dangling function pointers, re-open identity. Keep v1 semantics honest and
|
||||
simple: `dlclose` runs destructors and unmaps; holding pointers past it is
|
||||
undefined; no reference-counted deferral cleverness.
|
||||
- **The ReleaseSafe fact still applies to `.so`s** — a ReleaseSafe shared object
|
||||
carries its inlined checks like any static code; D3's sharing saves *copies*,
|
||||
not check overhead. Size expectations should be set accordingly.
|
||||
|
||||
## Decisions needing sign-off
|
||||
|
||||
- Dynamic libraries join the roadmap at all (this note exists because the
|
||||
earlier size-motivated rejection was re-opened for ABI/sharing reasons).
|
||||
- **Bind-now only; no lazy binding, permanently.**
|
||||
- **Loader in userspace libc; kernel involvement only at D3** (shared read-only
|
||||
mappings).
|
||||
- **Static executables until D4**, and D4 only on demonstrated need.
|
||||
|
||||
## Related
|
||||
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — the sysroot the
|
||||
loader ships in; its absence table gains `dlfcn.h` at D1.
|
||||
- [python-on-danos.md](python-on-danos.md) — the `ctypes` story this unlocks.
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — sequencing;
|
||||
this work is post-P5.
|
||||
- [os-development/memory-map.md](os-development/memory-map.md) /
|
||||
[os-development/paging.md](os-development/paging.md) — where W^X and shared
|
||||
mappings land.
|
||||
@@ -1,128 +0,0 @@
|
||||
# DanOS Filesystem Hierarchy Standard (DFHS)
|
||||
|
||||
Most modern Unix and Unix-like operating systems follow the FHS. DanOS has its own FHS structure which extends the unix FHS. Root path resolution is provided by the kernel-resident VFS root (`fs_resolve`, `system/kernel/vfs.zig`); mounted filesystem servers serve the subtrees they own.
|
||||
|
||||
## Directory structure
|
||||
|
||||
| Path | Description |
|
||||
|------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| / | Primary hierarchy root and root directory of the entire file system hierarchy. |
|
||||
| /bin | Essential command binaries that need to be available in single-user mode, including to bring up the system or repair it, for all users (e.g., cat, ls, cp). |
|
||||
| /boot | Boot loader files (e.g., EFI, initial-ramdisk.img ). |
|
||||
| /dev | POSIX Device files (e.g., /dev/null, /dev/disk0, /dev/tty, /dev/random). |
|
||||
| /etc | Host-specific system-wide configuration files. |
|
||||
| /home | Users' home directories, containing saved files, personal settings, etc. |
|
||||
| /lib | Libraries essential for the binaries in /bin and /sbin. eg realtime, system, ipc etc. |
|
||||
| /sbin | Essential system binaries (e.g init) |
|
||||
| /srv | Site-specific data served by this system, such as data and scripts for web servers, data offered by FTP servers, and repositories for version control systems |
|
||||
| /system | DanOS operating system files (similar idea to C:\Windows). A true representation of danos — its layout mirrors the source tree, so `/system` is what danos *is*. |
|
||||
| /system/devices | danos virtual device tree e.g. similar to /sys on linux but with danos device tree conventions (the structures in the devices module) |
|
||||
| /system/drivers | driver binaries, one sub-project each (e.g. /system/drivers/pci-bus, /system/drivers/ps2-bus) |
|
||||
| /system/services | system-service binaries — init, the FAT server, and other user-mode servers (e.g. /system/services/init, /system/services/fat) |
|
||||
| /system/kernel | the kernel image |
|
||||
| /test | Test fixtures for the QEMU integration suite. Read-only and initrd-backed like /system, and its layout likewise mirrors the source tree (the repo's test/ directory). Present on development and test images; a volume without it still boots. |
|
||||
| /test/system/services | test-fixture binaries (e.g. /test/system/services/vfs-test, /test/system/services/thread-test) — the same path in the repo source tree and on the boot volume |
|
||||
| /tmp | Directory for temporary files (see also /var/tmp). Often not preserved between system reboots and may be severely size-restricted. |
|
||||
| /usr | Secondary hierarchy for read-only user data; contains the majority of (multi-)user utilities and applications. Should be shareable and read-only. |
|
||||
| /var | Variable files: files whose content is expected to continually change during normal operation of the system, such as logs, spool files, and temporary e-mail files. |
|
||||
|
||||
## File types
|
||||
|
||||
POSIX specifies the long format of the ls command to represent the Unix file type as the first letter for an entry.
|
||||
|
||||
| type | symbol | Description |
|
||||
|-------------------|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| regular | - | An ordinary file holding an uninterpreted byte stream. Reads and writes are positional, and the file grows on demand (e.g., a binary in /bin, a config file in /etc). |
|
||||
| directory | d | A container mapping names to other files. It may only be modified through directory operations, never written to directly. |
|
||||
| symbolic link | l | A file whose contents are a path that is resolved in its place. The target need not exist, and may cross mount points. |
|
||||
| FIFO special | p | A named pipe: an in-order byte stream between processes, where writers block until a reader opens the other end. |
|
||||
| block special | b | A device node addressed in fixed-size blocks with the kernel free to buffer and reorder access (e.g., /dev/disk0). |
|
||||
| character special | c | A device node addressed as an unbuffered byte stream, delivered to the driver in order (e.g., /dev/tty, /dev/null). |
|
||||
| socket | s | A named endpoint for bidirectional message-passing between processes, bound to a path rather than an address. |
|
||||
|
||||
## /dev
|
||||
|
||||
`/dev` holds the names through which processes reach devices. It is deliberately not
|
||||
the device tree: the tree — every node discovered by ACPI or PCI enumeration, with its
|
||||
resources and its parent — lives under [/system/devices](#directory-structure) and is
|
||||
addressed by device id. `/dev` is the much smaller set of devices that have a driver
|
||||
willing to serve them, addressed by name.
|
||||
|
||||
A device node is not a file the VFS can read. The bytes live in a driver process
|
||||
([drivers.md](../device-driver-development/drivers.md)), so opening a `/dev` name has to resolve to that driver's
|
||||
IPC endpoint, and subsequent reads and writes are calls against it. Resolve-to-endpoint
|
||||
is exactly what the kernel's `fs_resolve` already does for any mounted backend, and
|
||||
`FileStatus.kind` is the field that marks a device node; **what is not implemented today
|
||||
is `/dev` itself** — no service mounts it. (The flat eight-node ramfs this section once
|
||||
described is retired: the kernel-resident VFS root in `system/kernel/vfs.zig` serves a
|
||||
read-only initrd mount per top-level tree — `/system`, and `/test` on images that carry
|
||||
the fixtures — with real directories and node kinds, and filesystem
|
||||
backends such as the FAT server mount the rest.) The three sections below describe the
|
||||
intended shape, and are honest about which parts the kernel can already support.
|
||||
|
||||
### Character devices
|
||||
|
||||
A character device is a byte stream with no addressable position: bytes are delivered
|
||||
to the driver in the order written, and a read consumes what is there. Terminals,
|
||||
serial lines, keyboards and mice are all of this shape. These are the natural first
|
||||
device nodes in danos, because a character driver needs nothing the kernel doesn't
|
||||
already provide — it claims its device, maps its registers with `mmio_map`, and blocks
|
||||
on `replyWait` for either an interrupt or a client request. `system/drivers/ps2-bus/ps2-bus.zig`
|
||||
is already that program, minus the file-node client half.
|
||||
|
||||
The obstacle was never the file type; it is which hardware a ring-3 driver can reach.
|
||||
Direct `in`/`out` from user space is still a #GP (no TSS I/O bitmap, IOPL never raised),
|
||||
but a driver no longer needs it: **`io_read`/`io_write`** grant port access the same way
|
||||
`mmio_map` grants memory — gated by `device_claim` and the device's discovered `io_port`
|
||||
resource. So the 16550 UART at `0x3F8` and the PS/2 controller at `0x60`/`0x64` (and thus
|
||||
`/dev/ttyS0` and a keyboard node) are now writable as ordinary ring-3 drivers; the
|
||||
low-rate legacy hardware that needs port I/O is fine with a syscall per access. A
|
||||
memory-mapped device such as the framebuffer, needing no port I/O at all, remains the
|
||||
easiest first entry.
|
||||
|
||||
### Block devices
|
||||
|
||||
A block device is addressed in fixed-size blocks and, unlike a character device, the
|
||||
layer above is free to buffer, reorder, coalesce and retry requests against it. Disks
|
||||
and other persistent storage are the whole population of this class.
|
||||
|
||||
A block driver is now **writable, but not yet memory-safe.** Every storage controller
|
||||
worth naming is a bus master: it is programmed by handing it the physical address of a
|
||||
descriptor ring and left to read and write memory on its own. That ring is exactly what
|
||||
**`dma_alloc`** now provides — physically contiguous, pinned, uncacheable, with its
|
||||
physical address disclosed — and **`/lib/device/mmio`**'s barriers order the descriptor writes
|
||||
against the doorbell, and **`msi_bind`** delivers completions. So an AHCI or NVMe driver
|
||||
can be written today (the M14/M15 work in [driver-model.md](../device-driver-development/driver-model.md); the earlier
|
||||
"cannot host a block driver at all" is no longer true).
|
||||
|
||||
What is *not* yet true is that it is safe. A device programmed with an arbitrary physical
|
||||
address writes to arbitrary physical memory, and page tables do not sit between a device
|
||||
and RAM — an IOMMU does. The IOMMU is now *detected* (M16), but no translation domains
|
||||
are programmed, so granting a DMA-capable device to a driver process is still equivalent
|
||||
to granting ring 0. Until per-device domains confine a driver's DMA to the buffers it
|
||||
`dma_alloc`'d, a block driver works but forfeits the isolation that motivates user-space
|
||||
drivers — enforcement is the next step, and lands with that first driver. A ramdisk over
|
||||
the initial ramdisk remains the one block-shaped thing that needs no driver process at all.
|
||||
|
||||
### Pseudo-devices
|
||||
|
||||
A pseudo-device has the interface of a device and no hardware behind it: `/dev/null`
|
||||
discarding writes and reading as end-of-file, `/dev/zero` reading as an endless run of
|
||||
zero bytes, `/dev/full` failing writes with `ENOSPC`, `/dev/random` and `/dev/urandom`
|
||||
yielding unpredictable bytes.
|
||||
|
||||
These are the only `/dev` entries danos can implement immediately, and they are the
|
||||
sensible place to start, because they are exactly the entries that need no driver
|
||||
process, no `device_claim`, no MMIO grant and no interrupt. A future pseudo-device
|
||||
service would answer them out of its own address space — `null` and `zero` are a few
|
||||
lines each in its `read` and `write` handlers — and mount itself at `/dev` the way the
|
||||
FAT server mounts `/mnt/usb`. The two pieces of structure every later device node
|
||||
depends on (and that the flat ramfs of the time lacked) exist now: directories, so that
|
||||
`/dev/null` is a path rather than a name; and a populated `FileStatus.kind`, so that a
|
||||
caller can tell a character device from a regular file.
|
||||
|
||||
`/dev/random` is the one that is not free. It needs an entropy source, and the honest
|
||||
options on this kernel are `RDRAND`/`RDSEED` where CPUID advertises them, and the HPET
|
||||
counter's low bits as a poor fallback. Neither is a seeded CSPRNG, and a `/dev/random`
|
||||
that is merely unpredictable-looking is worse than none — nothing should be keyed from
|
||||
it until it is a real one.
|
||||
@@ -0,0 +1,90 @@
|
||||
# The danos file-system hierarchy
|
||||
|
||||
danos is not unix, and its tree does not follow the unix FHS. Paths are the
|
||||
system's universal namespace — files, the device inventory, and protocol
|
||||
endpoints all live in one tree — but what a path *yields* differs by subtree:
|
||||
bytes, facts, or a connection. Root path resolution is provided by the
|
||||
kernel-resident VFS root (`fs_resolve`, `system/kernel/vfs.zig`); mounted
|
||||
backends serve the subtrees they own.
|
||||
|
||||
Naming follows the codebase conventions: kebab-case, full words, no
|
||||
abbreviations. Every top-level name says what its subtree *is*.
|
||||
|
||||
## The tree
|
||||
|
||||
| Path | What it is |
|
||||
|-------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `/` | The root of the one namespace. |
|
||||
| `/applications` | Installed applications, one directory per application — the directory is the identity, the same rule as source sub-projects. *(Planned; empty today.)* |
|
||||
| `/protocol` | The contract namespace: one protocol node per contract, grouped into directories by domain (`/protocol/display`, `/protocol/networking/ip`). Synthetic — no bytes; opening a name yields a connection to the current provider. See [protocol-namespace.md](../os-development/protocol-namespace.md). |
|
||||
| `/system` | The operating system — what danos *is*. Its program subtrees mirror the source tree exactly. |
|
||||
| `/system/kernel` | The kernel image. |
|
||||
| `/system/drivers` | Driver binaries, one per sub-project (`/system/drivers/pci-bus`, `/system/drivers/ps2-bus`). |
|
||||
| `/system/services` | System-service binaries (`/system/services/init`, `/system/services/fat`). |
|
||||
| `/system/devices` | The device inventory: every node hardware discovery found, with its resources and parent — the structures of the devices module, as a browsable virtual tree. Informational only; you *read about* hardware here and *talk to* it through `/protocol`. *(Planned; served by device-manager.)* |
|
||||
| `/system/configuration` | Machine configuration (`init.csv`, `devices.csv`). Writable, served from the boot volume. |
|
||||
| `/system/logs` | Per-boot logs: `/system/logs/<boot-stamp>/<binary-path>.log`. Writable, served from the boot volume. |
|
||||
| `/test` | Test fixtures for the QEMU integration suite. Read-only and initrd-backed like the program subtrees of `/system`, mirroring the repo's `test/` directory. Present on development and test images; a volume without it still boots. |
|
||||
| `/volumes` | Attached storage volumes, one directory per volume (`/volumes/usb`). A volume's own tree appears beneath its name. |
|
||||
|
||||
Read-only and writable halves of `/system`: the program subtrees (`kernel`,
|
||||
`drivers`, `services`) and the future `devices` are immutable at runtime —
|
||||
initrd-backed or synthetic — while `configuration` and `logs` are mutable
|
||||
machine state served by the boot-volume FAT backend. The kernel's
|
||||
reserved-prefix rule (no mount may shadow `/system`, `/test`, or `/protocol`)
|
||||
needs a carve-out for exactly these two writable subtrees; that lands with the
|
||||
path migration below.
|
||||
|
||||
Deliberately not defined yet: a temporary-files location and per-application
|
||||
mutable storage. Both belong to the `/applications` design and will be
|
||||
specified there, not guessed at here.
|
||||
|
||||
## Node kinds
|
||||
|
||||
What a path resolves to. These fill `FileStatus.kind` and
|
||||
`DirectoryEntry.kind` in the [vfs protocol](vfs-protocol.md)
|
||||
(`library/protocol/vfs/vfs-protocol.zig`); enum values are append-only.
|
||||
|
||||
| Kind | Meaning |
|
||||
|--------------------|-------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `regular` | An ordinary file: an uninterpreted byte stream, positional reads and writes, grows on demand. |
|
||||
| `directory` | A container mapping names to nodes; modified only through directory operations. |
|
||||
| `character_device` | A node whose read/write have **stream semantics**: unseekable, reads block until bytes exist, size is meaningless. The console and every tty-shaped node ([character-devices-and-tty.md](../character-devices-and-tty.md)); what a POSIX layer's `isatty` detects. |
|
||||
| `block_device` | A node addressed in fixed-size sectors — a raw volume. Reserved: recognized, nothing serves one yet. |
|
||||
| `symbolic_link` | Reserved: a recognized value, not implemented by any backend. |
|
||||
| `fifo` | Reserved for the future pipe object (wanted by the POSIX compatibility layer); not implemented. |
|
||||
| `protocol` | A node naming a contract: `open` yields an IPC connection (an endpoint capability) instead of a file id — the kind of every leaf under `/protocol`. *(Being added; see protocol-namespace.md.)* |
|
||||
|
||||
Note the layering: `protocol` says what *opening the name* does (you get a
|
||||
conversation); `character_device`/`block_device` say what *read and write
|
||||
mean* on a node a provider serves you. The two compose — `/protocol/console`
|
||||
is a protocol node in the registry, and the node opened over that connection
|
||||
reports `character_device`, which is what gives it stream semantics. Only
|
||||
`socket` is retired (its value stays reserved for wire stability): a named
|
||||
rendezvous point is exactly what a protocol node is.
|
||||
|
||||
## What is deliberately absent
|
||||
|
||||
There is no `/bin`, `/boot`, `/dev`, `/etc`, `/home`, `/lib`, `/mnt`, `/sbin`,
|
||||
`/srv`, `/tmp`, `/usr`, or `/var`. These encode unix history — the
|
||||
binary/library split of small disks, configuration-as-scattered-text, devices
|
||||
as magic files — that danos does not carry. A POSIX compatibility layer (the
|
||||
Python track's mini-libc) may *present* whichever of these its programs
|
||||
expect, mapped onto the real tree; the tree itself stays danos-native.
|
||||
|
||||
## Migration
|
||||
|
||||
The tree above is the specification; some code still writes the unix paths it
|
||||
replaced. The flag-day converting them:
|
||||
|
||||
| Today (in code) | Becomes | Where |
|
||||
|------------------------------------------|-------------------------------------------|-----------------------------------------------------------------|
|
||||
| `/etc/init.csv` | `/system/configuration/init.csv` | `system/services/init/init.zig` |
|
||||
| `/etc/devices.csv` | `/system/configuration/devices.csv` | `system/services/device-manager/device-manager.zig` |
|
||||
| `/var/log/...` | `/system/logs/...` | `system/services/logger/logger.zig`, the FAT server's `/var` mount |
|
||||
| `/mnt/usb` | `/volumes/usb` | `system/services/fat/fat.zig`, the fat/vfs tests |
|
||||
| `ServiceId` lookup | resolve + open under `/protocol` | every service and client; [protocol-namespace.md](../os-development/protocol-namespace.md) |
|
||||
|
||||
The boot-image builder and the on-volume directory layout move in the same
|
||||
change, so a freshly written image and the paths the services expect never
|
||||
disagree.
|
||||
@@ -6,7 +6,10 @@
|
||||
> serves it directly (the read-only /system initrd mount, via `fs_node`) or
|
||||
> redirects the caller to the owning backend's endpoint plus the rewritten
|
||||
> mount-relative path — after which the client speaks THIS protocol to the
|
||||
> backend, unchanged. The Zig source of truth is `library/protocol/vfs/vfs-protocol.zig`
|
||||
> backend, unchanged. Since P4a the contract is expressed through
|
||||
> `envelope.Define` (docs/os-development/protocol-namespace.md), so every
|
||||
> packet begins with the universal 16-byte prefix and the open-node id rides
|
||||
> in it. The Zig source of truth is `library/protocol/vfs/vfs-protocol.zig`
|
||||
> (the `vfs-protocol` module), whose unit test pins a sample of the sizes
|
||||
> and values below. This page is the **language-neutral wire specification**
|
||||
> of that contract — what a Rust or C client implements ([vdso.md](../os-development/vdso.md)
|
||||
@@ -22,12 +25,17 @@ also hands back the path rewritten relative to the mount — not from a
|
||||
registry lookup. (Service id 1, the old userspace router, is retired.)
|
||||
|
||||
- A message is at most **256 bytes** (`message_maximum`).
|
||||
- A request is a fixed 32-byte **Request** header followed by an inline
|
||||
payload of at most **224 bytes** (`maximum_payload`) — a path, or write
|
||||
bytes. There is no multi-message request: paths and single reads/writes
|
||||
must fit, and larger transfers loop (see *read* / *write*).
|
||||
- A reply is a fixed 24-byte **Reply** header followed by an inline payload —
|
||||
read bytes, a `FileStatus`, or a `DirectoryEntry`.
|
||||
- Every packet begins with the 16-byte **envelope prefix**
|
||||
([protocol-namespace.md](../os-development/protocol-namespace.md)): a
|
||||
`Header` on a request, a `Status` on a reply. The prefix is **folded, not
|
||||
stacked** — the verb and the object being addressed live in it, and no
|
||||
request or reply below repeats either.
|
||||
- A request is the header, then the verb's own fixed part (0–16 bytes), then
|
||||
an inline tail of at most **224 bytes** (`maximum_payload`) — a path, or
|
||||
write bytes. There is no multi-message request: paths and single
|
||||
reads/writes must fit, and larger transfers loop (see *read* / *write*).
|
||||
- A reply is the status, then the verb's own fixed part, then an inline tail
|
||||
— read bytes, or a directory entry's name.
|
||||
- All integers are **little-endian**; layouts are C layout for x86-64
|
||||
(`extern struct`), offsets given below so nothing need be inferred.
|
||||
|
||||
@@ -37,91 +45,108 @@ With clients holding backend node ids directly, a backend records each open
|
||||
handle's owner and sweeps a dead client's handles via the published process
|
||||
exit events.
|
||||
|
||||
## Request header — 32 bytes
|
||||
## Request header — 16 bytes
|
||||
|
||||
The envelope's `Header`, identical in every danos protocol:
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|-------:|-----:|-------|---------|
|
||||
| 0 | 4 | `operation` | an **Operation** value (below) |
|
||||
| 0 | 4 | `operation` | an **Operation** value (below); 0–15 are the reserved universal verbs |
|
||||
| 4 | 4 | — | padding |
|
||||
| 8 | 8 | `node` | the server-side open-node id from a prior `open`; 0 for path-based operations |
|
||||
| 16 | 8 | `offset` | byte position for read/write; entry index (cursor) for readdir; else 0 |
|
||||
| 24 | 4 | `len` | payload length for path/write operations; requested byte count for read |
|
||||
| 28 | 4 | `flags` | open flags (below); else 0 |
|
||||
| 8 | 8 | `target` | **the open-node id** from a prior `open`; 0 for `open` itself and the path-based verbs |
|
||||
|
||||
## Reply header — 24 bytes
|
||||
## Reply header — 16 bytes
|
||||
|
||||
The envelope's `Status`:
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|-------:|-----:|-------|---------|
|
||||
| 0 | 4 | `status` | **0 = success**, negative = failure (signed) |
|
||||
| 4 | 4 | — | padding |
|
||||
| 8 | 8 | `node` | the new open-node id (for `open`); else 0 |
|
||||
| 16 | 4 | `len` | reply payload length in bytes |
|
||||
| 20 | 4 | — | padding |
|
||||
| 8 | 4 | `len` | reply bytes following this header: the verb's fixed part plus its tail |
|
||||
| 12 | 4 | — | padding |
|
||||
|
||||
On failure the backend replies `status = -1`, and that reply reaches the
|
||||
client directly — there is no party between them on the wire. (Kernel-served
|
||||
paths produce no wire replies at all: `fs_resolve`/`fs_node` failures are
|
||||
syscall register statuses.) A richer errno vocabulary is future work —
|
||||
clients must treat *any* negative status as failure, not match on -1.
|
||||
A failing backend replies with the status alone (`len` = 0) and no fixed
|
||||
part, and that reply reaches the client directly — there is no party between
|
||||
them on the wire. (Kernel-served paths produce no wire replies at all:
|
||||
`fs_resolve`/`fs_node` failures are syscall register statuses.) The errno
|
||||
vocabulary is the kernel's, continued by the envelope: `ENOENT` = 4 is what a
|
||||
backend answers for anything it cannot find or cannot do, `ENOSYS` = 10 for a
|
||||
verb it does not implement, `EPROTO` = 11 for a packet shorter than the verb
|
||||
it names. Clients must treat *any* negative status as failure rather than
|
||||
matching a particular one.
|
||||
|
||||
## Operations
|
||||
|
||||
Values are append-only and never renumbered (the same evolution rule every
|
||||
danos protocol follows). Send only values from this table: the shipped server
|
||||
decodes the operation into an exhaustive enum, so an out-of-range value is
|
||||
not answered with a `status = -1` reply — it trips a safety check in safe
|
||||
builds and is undefined otherwise. (The `-1` replies cover recognised but
|
||||
refused operations, such as `mount` sent to a backend.)
|
||||
Values number from 16 (`first_protocol_operation`) in declaration order, and
|
||||
are frozen once shipped. Values 0–15 are the envelope's reserved universal
|
||||
verbs, which mean the same thing at every provider in the system: `describe`
|
||||
(0) answers the protocol's name and version and is implemented by the
|
||||
envelope itself, so every backend answers it. A verb outside this table is
|
||||
answered `-ENOSYS`; it is never a safety check any more, because the
|
||||
dispatch compares numbers rather than decoding an enum.
|
||||
|
||||
| value | operation | request payload | reply |
|
||||
|------:|-----------|-----------------|-------|
|
||||
| 0 | `open` | the path (`len` = its length), `flags` as below | `node` = open-node id |
|
||||
| 1 | `close` | — (`node` set) | status only |
|
||||
| 2 | `read` | — (`node`, `offset`, `len` = wanted count) | `len` bytes read, payload = the bytes; `len` 0 at end of file |
|
||||
| 3 | `write` | the bytes (`node`, `offset`, `len` = count) | `len` = bytes accepted (may be short — loop) |
|
||||
| 4 | `status` | — (`node` set) | payload = **FileStatus** (24 bytes) |
|
||||
| 5 | `readdir` | — (`node` = a directory, `offset` = cursor) | payload = one **DirectoryEntry** + name; `len` 0 at end |
|
||||
| 6 | `mount` | the mount-point path; the backend endpoint rides as the call's **capability** | status only |
|
||||
| 7 | `unmount` | the mount-point path | status only |
|
||||
| 8 | `mkdir` | the path | status only |
|
||||
| 9 | `unlink` | the path | status only |
|
||||
| 10 | `rename` | old path, one `0x00`, new path (`len` = total) | status only |
|
||||
Each row's *request* and *reply* name the bytes **after** the 16-byte prefix.
|
||||
|
||||
| value | operation | request | tail | reply | reply tail |
|
||||
|------:|-----------|---------|------|-------|-----------|
|
||||
| 16 | `open` | `flags` (4 bytes, below) | the path | `node` (8 bytes) = the open-node id | — |
|
||||
| 17 | `close` | — | — | — | — |
|
||||
| 18 | `read` | `offset` (8), `len` (4) = wanted count | — | — | the bytes read; `Status.len` 0 at end of file |
|
||||
| 19 | `write` | `offset` (8), `len` (4) = count | the bytes | `count` (4) = bytes accepted (may be short — loop) | — |
|
||||
| 20 | `status` | — | — | **FileStatus** (24 bytes) | — |
|
||||
| 21 | `readdir` | `cursor` (8) | — | one **DirectoryEntry** (16 bytes) | the name |
|
||||
| 22 | `mount` | — | the mount-point path; the backend endpoint rides as the call's **capability** | — | — |
|
||||
| 23 | `unmount` | — | the mount-point path | — | — |
|
||||
| 24 | `mkdir` | — | the path | — | — |
|
||||
| 25 | `unlink` | — | the path | — | — |
|
||||
| 26 | `rename` | — | old path, one `0x00`, new path | — | — |
|
||||
| 27 | `bind` | — | the contract name; the provider's endpoint rides as the call's **capability** | — | — |
|
||||
|
||||
Notes per operation:
|
||||
|
||||
- **open** — the path is the mount-relative path `fs_resolve` handed back
|
||||
(absolute-shaped: `/notes.txt` under fat's `/mnt/usb` mount). Bare names
|
||||
(absolute-shaped: `/notes.txt` under fat's `/volumes/usb` mount). Bare names
|
||||
(`greeting`) resolve nowhere — the flat ramfs is retired, and `fs_resolve`
|
||||
refuses non-absolute paths. The returned `node` is the *backend's* own
|
||||
open-node id: with the router in the kernel there is no forwarding table,
|
||||
and clients hold backend ids directly (see *Lifetimes and trust*).
|
||||
and clients hold backend ids directly (see *Lifetimes and trust*). Every
|
||||
later packet carries it in `Header.target` — the path is spoken once, here,
|
||||
and integers do the rest.
|
||||
- **read / write** — a single exchange moves at most 224 bytes
|
||||
(`maximum_payload`); the client loops, advancing `offset` by the returned
|
||||
`len`, until done (read) or the slice is written (write). A `write` reply
|
||||
shorter than requested is progress, not an error; a `len` of 0 means no
|
||||
forward progress — stop rather than spin.
|
||||
- **readdir** — `offset` is a **cursor: the entry index**, not a byte
|
||||
position. Each call returns exactly one entry; the client increments the
|
||||
cursor by 1. A reply with `len` 0 is end-of-directory. The directory must
|
||||
have been opened with the `directory` flag.
|
||||
(`maximum_payload`); the client loops, advancing its own offset by what
|
||||
came back, until done (read) or the slice is written (write). A `write`
|
||||
reply shorter than requested is progress, not an error; a count of 0 means
|
||||
no forward progress — stop rather than spin.
|
||||
- **readdir** — `cursor` is the **entry index**, not a byte position. Each
|
||||
call returns exactly one entry; the client increments the cursor by 1. **A
|
||||
`name_len` of 0 is end-of-directory** — the reply's own length cannot say
|
||||
so, because the envelope always sends the fixed reply part. The directory
|
||||
must have been opened with the `directory` flag.
|
||||
- **mount / unmount** — RETIRED from the wire: mounting is the `fs_mount`
|
||||
syscall now (a filesystem server passes its endpoint handle; possession is
|
||||
the capability, exactly the trust of the old cap-passing op). The op
|
||||
the capability, exactly the trust of the old cap-passing op). The verb
|
||||
numbers stay reserved. Mount-prefix semantics are unchanged: prefixes
|
||||
match at path boundaries only (`/mnt/usb` never captures `/mnt/usbextra`),
|
||||
the longest matching prefix wins, and an optional backend-side rewrite
|
||||
prefix maps a mount into the backend's namespace (fat serves `/mnt/usb`
|
||||
from its volume root and `/var` from its `/var` subtree).
|
||||
match at path boundaries only (`/volumes/usb` never captures
|
||||
`/volumes/usbextra`), the longest matching prefix wins, and an optional
|
||||
backend-side rewrite prefix maps a mount into the backend's namespace (fat
|
||||
serves `/volumes/usb` from its volume root and `/system/logs` from its
|
||||
`/system/logs` subtree).
|
||||
- **rename** — same-directory rename only: the backend compares the old and
|
||||
new parent paths and refuses a mismatch. The client (`file_system`) refuses
|
||||
earlier when the two paths resolve to different backend endpoints, but that
|
||||
check is coarser than "one mount" — one endpoint can serve several mounts
|
||||
(fat serves `/mnt/usb` and `/var`), so a cross-mount rename reaches the
|
||||
backend and fails on its same-directory check.
|
||||
(fat serves `/volumes/usb`, `/system/configuration` and `/system/logs`), so
|
||||
a cross-mount rename reaches the backend and fails on its same-directory
|
||||
check.
|
||||
- **bind** — the protocol registry's claim verb, implemented only by the
|
||||
synthetic `/protocol` backend inside PID 1
|
||||
([protocol-namespace.md](../os-development/protocol-namespace.md)). A file
|
||||
backend answers `-ENOSYS`.
|
||||
|
||||
## Open flags
|
||||
|
||||
Bitwise OR in `Request.flags`, meaningful for `open` only:
|
||||
Bitwise OR in `open`'s `flags`, meaningful for `open` only:
|
||||
|
||||
| bit | name | meaning |
|
||||
|----:|------|---------|
|
||||
@@ -129,7 +154,7 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
|
||||
| 2 | `directory` | open a directory node for `readdir` rather than a file |
|
||||
| 4 | `truncate` | truncate an existing file to zero length on open (replace, don't overwrite in place) |
|
||||
|
||||
## FileStatus — 24 bytes (the `status` reply payload)
|
||||
## FileStatus — 24 bytes (the `status` reply's fixed part)
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|-------:|-----:|-------|---------|
|
||||
@@ -138,19 +163,19 @@ Bitwise OR in `Request.flags`, meaningful for `open` only:
|
||||
| 12 | 4 | — | padding |
|
||||
| 16 | 8 | `mtime` | modification time, Unix epoch seconds UTC; 0 if the backend keeps none |
|
||||
|
||||
## DirectoryEntry — 16 bytes + name (the `readdir` reply payload)
|
||||
## DirectoryEntry — 16 bytes + name (the `readdir` reply)
|
||||
|
||||
| offset | size | field | meaning |
|
||||
|-------:|-----:|-------|---------|
|
||||
| 0 | 4 | `kind` | a **NodeKind** value |
|
||||
| 4 | 4 | `name_len` | length of the name that follows |
|
||||
| 4 | 4 | `name_len` | length of the name that follows; **0 means end of directory** |
|
||||
| 8 | 8 | `size` | the entry's size in bytes |
|
||||
| 16 | `name_len` | name | the entry's name, not NUL-terminated |
|
||||
|
||||
## NodeKind
|
||||
|
||||
Aligned to the FSH file-type table
|
||||
(docs/danos-file-system-hierarchy-FSH.md):
|
||||
Aligned to the node-kind table in the file-system hierarchy
|
||||
(docs/file-system-development/file-system-hierarchy.md):
|
||||
|
||||
| value | kind |
|
||||
|------:|------|
|
||||
@@ -161,9 +186,25 @@ Aligned to the FSH file-type table
|
||||
| 4 | symbolic link |
|
||||
| 5 | fifo |
|
||||
| 6 | socket |
|
||||
| 7 | protocol |
|
||||
|
||||
Clients should map unknown values to *regular* rather than reject — the
|
||||
table can grow.
|
||||
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,
|
||||
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. 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
|
||||
|
||||
@@ -171,10 +212,24 @@ Open-node ids live in the backend. A client that dies without closing leaks
|
||||
nothing permanently: the backend (the FAT server) subscribes to the kernel's
|
||||
published process-exit events (docs/process-lifecycle.md) and releases a dead
|
||||
client's handles. The kernel VFS root needs no sweep at all — its node tokens
|
||||
are permanent for a boot and carry no open state. Ids are plain integers, not
|
||||
capabilities — a backend trusts its callers with each other's ids today, which
|
||||
is acceptable while every client is part of the system image and worth
|
||||
revisiting (per-client id namespaces) before third-party binaries arrive.
|
||||
are permanent for a boot and carry no open state.
|
||||
|
||||
Ids are plain integers rather than capabilities, so the backend **scopes them
|
||||
to the caller's badge**: an open node belongs to the task that opened it, and
|
||||
every verb that names one — read, write, status, readdir, close — is answered
|
||||
only for that task. A node id is a small number drawn from a table of
|
||||
thirty-two, trivially guessable, and until this rule a backend honoured every
|
||||
client's ids from every other client
|
||||
(docs/os-development/protocol-namespace.md: *handles must be scoped per
|
||||
client — validated against the badge, or drawn from a per-client id
|
||||
namespace*).
|
||||
|
||||
The refusal is deliberately **identical to absence**: a node that is somebody
|
||||
else's answers `-ENOENT`, exactly as one that was never opened, so a prober
|
||||
learns nothing about which ids are live — the same discipline the protocol
|
||||
namespace applies to a refused open. The owner is a *task*, because the badge
|
||||
is: a threaded client uses a node from the thread that opened it, which is
|
||||
already the granularity of the exit sweep that releases it.
|
||||
|
||||
## Evolution rules
|
||||
|
||||
@@ -182,11 +237,15 @@ 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.
|
||||
`library/protocol/vfs/vfs-protocol.zig` pins a sample of them (the
|
||||
`DirectoryEntry` size, `NodeKind` 0–1 and 6–7, `Operation` values 16–21, 26
|
||||
and 27); this page is the full record of the frozen values.
|
||||
*The one renumbering this contract has had was the rebase onto the envelope
|
||||
(P4a), which moved every verb above the reserved range — a deliberate
|
||||
flag-day across a system with no third-party clients yet, not a precedent.*
|
||||
- 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.
|
||||
- Negative statuses beyond -1 will appear (an errno vocabulary); success is
|
||||
exactly 0.
|
||||
- Success is exactly 0, and the negative statuses come from one system-wide
|
||||
errno vocabulary (the kernel's, continued by the envelope) rather than from
|
||||
this protocol.
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
# Communication: the four layers
|
||||
|
||||
*Design, agreed 2026-07-31. The model document — the vocabulary and layering
|
||||
every other communication document speaks.*
|
||||
|
||||
danos separates **what is said** from **how the bytes move**, so that the
|
||||
mechanism is replaceable. The shape is a network stack's, cut into four
|
||||
layers; a program only ever touches the top two.
|
||||
|
||||
```
|
||||
L3 namespace /protocol/... names establishment points protocol-namespace.md
|
||||
L2 protocol the language: packet schemas, verbs, targets the envelope, library/protocol/*
|
||||
L1 channel two ends exchanging packets and signals the client library's Channel
|
||||
L0 transport a buffer + a doorbell: moves the bytes ipc.md (kernel-ipc), later shm-ring, …
|
||||
```
|
||||
|
||||
## Vocabulary
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **protocol** | The language: which packets exist, what their fields mean, which verbs a provider answers. Defined transport-independently in a `library/protocol/*` module. |
|
||||
| **channel** | An open conversation between two processes, speaking one protocol. Established by opening a `/protocol/...` name; both ends can send and receive. |
|
||||
| **packet** | The unit a protocol transmits: a bounded, atomic header+payload. Never fragmented — if it doesn't fit, it isn't a packet; bulk data rides shared memory with a packet as the doorbell. |
|
||||
| **signal** | A payload-less poke below the packet layer: "something happened, come look." Coalescing — the count may collapse, the fact may not. |
|
||||
| **transport** | What moves the bytes of one channel: a buffer plus a doorbell. Chosen (and upgradable) at establishment, invisible above L1. |
|
||||
| **endpoint** | A termination point where a transport delivers. The kernel-ipc transport's endpoint is its kernel mailbox object. |
|
||||
|
||||
## Addressing: parties by channel, objects by target
|
||||
|
||||
There are no network-style addresses in a packet. The two questions addresses
|
||||
answer are answered at different layers:
|
||||
|
||||
- **Who am I talking to?** The **channel**, decided once at establishment.
|
||||
Opening `/protocol/input` yields a channel; every packet sent on it goes to
|
||||
the peer. Nothing to route per-packet — like TCP, where no HTTP request
|
||||
carries the server's IP.
|
||||
- **Who sent this?** Attached to every received packet **by the channel
|
||||
layer**, from identity the transport can verify — under kernel-ipc, the
|
||||
kernel-stamped badge. The sender never writes a source field, which is what
|
||||
makes source unforgeable (the property a network's spoofable source header
|
||||
lacks).
|
||||
- **Which of your things?** The packet's **`target`** field: *object*
|
||||
addressing within the already-chosen peer — the vfs protocol's node id, the
|
||||
display protocol's layer id, a block volume. `target = 0` addresses the
|
||||
provider itself; a protocol without objects never uses it.
|
||||
|
||||
`target` is how instance multiplicity stays out of the namespace. Ten USB
|
||||
sticks and the namespace still holds exactly one name, `/protocol/block`: a
|
||||
channel to the provider, `enumerate` lists the current volumes as targets, a
|
||||
`targets_changed` signal announces hotplug, and a read names its volume in
|
||||
`target`. The unix `/dev/sda`,`/dev/sdb` problem is dissolved, not renamed.
|
||||
|
||||
If a future transport genuinely routes between machines, *it* carries real
|
||||
source/destination addressing internally at L0 — the way IP runs under TCP —
|
||||
and none of it surfaces into the packet header. Protocols stay ignorant of
|
||||
distance.
|
||||
|
||||
## The transport (L0): a buffer and a doorbell
|
||||
|
||||
Strip any transport to its skeleton and the same two parts remain:
|
||||
|
||||
| Transport | Buffer | Doorbell | Status |
|
||||
|---|---|---|---|
|
||||
| **kernel-ipc** | kernel-owned mailbox (the `Endpoint`) | the scheduler (rendezvous wake) | the first transport — [ipc.md](../device-driver-development/ipc.md) |
|
||||
| **shm-ring** | user-owned shared-memory ring | a signal | exists ad hoc (display bulk); to be formalized — the unlock for the 256-byte ceiling |
|
||||
| network | NIC queue | an interrupt | someday, when danos networks |
|
||||
|
||||
Transports differ in their **properties**, which the channel layer exposes and
|
||||
the protocol layer may depend on:
|
||||
|
||||
- **packet ceiling** — kernel-ipc: 256 bytes request/reply, 64 pushed. An
|
||||
shm-ring's ceiling is its slot size. Kernel-ipc's 256 is the *floor* every
|
||||
protocol may assume everywhere.
|
||||
- **synchrony** — kernel-ipc's call is a rendezvous: natural backpressure, no
|
||||
queue to size. An asynchronous transport buffers, so a channel over one
|
||||
needs explicit flow control. Backpressure is a *transport property*, not a
|
||||
channel guarantee — protocols that rely on it say so.
|
||||
- **droppability** — pushed event packets may drop when a ring fills;
|
||||
request/reply may not.
|
||||
- **capability carriage** — **only kernel-ipc can move a capability.**
|
||||
Handles are kernel objects; a user-space ring cannot transfer one. So
|
||||
kernel-ipc is always the *establishment and control* transport — channels
|
||||
are born on it, capabilities ride it — even when a channel's data is
|
||||
negotiated onto something fatter.
|
||||
|
||||
That negotiation is the upgrade path: a channel starts on kernel-ipc; the
|
||||
protocol's handshake may then delegate a shared-memory region (as a
|
||||
capability, over kernel-ipc) and move its bulk traffic there. The display
|
||||
path already does exactly this by hand; formalizing it in the channel layer
|
||||
makes it every protocol's option.
|
||||
|
||||
## The channel (L1)
|
||||
|
||||
A channel has two ends, and **the ends are peers**: each may send packets,
|
||||
each may receive, each may signal. Request/reply is a *pattern* over the
|
||||
channel — a send with a correlated receive, which the kernel-ipc transport
|
||||
happens to accelerate as a single rendezvous — not the definition of it. The
|
||||
event stream (subscribe, then pushes) and the change signal (poke, then
|
||||
re-read) are the other two patterns; all three are catalogued in
|
||||
[protocol-namespace.md](protocol-namespace.md)'s wiring section.
|
||||
|
||||
The channel layer's obligations: deliver packets whole, attach the verified
|
||||
source to every receive, expose the transport's properties, and hide the
|
||||
transport's mechanics. The client library's `Channel` type is this layer made
|
||||
concrete — a program holds channels that speak protocols and never touches a
|
||||
raw handle.
|
||||
|
||||
## The protocol (L2) and the namespace (L3)
|
||||
|
||||
A protocol defines its packets through the envelope — every packet begins
|
||||
`{operation, target}`, reserved verbs (`describe`, `enumerate`, `subscribe`,
|
||||
`unsubscribe`) mean the same thing in every protocol, and `Define` checks
|
||||
every packet against the transport floor at compile time. The full treatment,
|
||||
including how names are granted, resolved, and restricted per process, is
|
||||
[protocol-namespace.md](protocol-namespace.md).
|
||||
|
||||
Establishment points are named by contract — `/protocol/display`, never
|
||||
`/protocol/ipc-1` — because the name must outlive the mechanism: a
|
||||
transport named in the namespace could never be swapped, which would defeat
|
||||
this document's premise.
|
||||
@@ -231,8 +231,8 @@ Two consequences of neutrality bind on later work:
|
||||
|
||||
- **Cross-firmware surfaces are named by domain, not firmware.** System power is
|
||||
a [`power`](power.md) protocol, not an "ACPI events" protocol: on x86 the acpi
|
||||
service registers it, on ARM a PSCI/mailbox service registers the same
|
||||
`ServiceId.power`, and subscribers never learn the difference.
|
||||
service binds it, on ARM a PSCI/mailbox service binds the same
|
||||
`/protocol/power`, and subscribers never learn the difference.
|
||||
- **Identity must widen before the fdt service exists.** `DeviceDescriptor`'s
|
||||
8-byte `hid` holds an EISA id but cannot hold an FDT `compatible` string
|
||||
(`"brcm,bcm2835-aux-uart"`); the identity field grows before the ARM path can
|
||||
|
||||
@@ -15,9 +15,9 @@ Where the events come from is firmware-specific — on x86 they ride the ACPI SC
|
||||
([acpi.md](acpi.md)); on a Raspberry Pi they would come from PSCI or a mailbox.
|
||||
What subscribers want is not: *the lid closed* means the same thing regardless of
|
||||
who noticed. So the surface is **domain-named**. There is a `power-protocol`
|
||||
module and a well-known `ServiceId.power = 5`; on x86 the **acpi service**
|
||||
registers it, and on ARM a PSCI/mailbox service will register the *same* id.
|
||||
Subscribers call `ipc.lookup(.power)` and never learn which firmware they
|
||||
module and a contract named `/protocol/power`; on x86 the **acpi service**
|
||||
binds it, and on ARM a PSCI/mailbox service will bind the *same* name.
|
||||
Subscribers open `/protocol/power` and never learn which firmware they
|
||||
are on — the neutrality the whole [discovery](discovery.md) migration exists to
|
||||
preserve, carried one layer up into a running-system surface.
|
||||
|
||||
@@ -28,28 +28,43 @@ unchanged.
|
||||
## The protocol
|
||||
|
||||
The `power-protocol` module ([library/protocol/power/power-protocol.zig](../../library/protocol/power/power-protocol.zig))
|
||||
follows the vfs-protocol pattern — extern-struct messages, a version, reserved
|
||||
fields. Three operations:
|
||||
is defined through the [envelope](protocol-namespace.md), so every packet begins
|
||||
with the folded `Header`. `Header.target` is unused in both directions: the
|
||||
provider is the only object either side addresses.
|
||||
|
||||
| Direction | Operation | Purpose |
|
||||
| Direction | Packet | Purpose |
|
||||
|---|---|---|
|
||||
| subscriber → service | `subscribe` | receive published events; the subscriber's endpoint rides as the call's **capability** (the input/device-manager pattern) |
|
||||
| init → service | `shutdown` | orderly shutdown's last step: enter S5 (soft off) |
|
||||
| service → subscriber | `event` | a published `EventMessage`, delivered as a buffered message (never sent *to* the service) |
|
||||
| subscriber → service | `subscribe` (reserved verb 2) | receive published events; the subscriber's endpoint rides as the call's **capability** (the input/device-manager pattern) |
|
||||
| init → service | `shutdown` (verb 16) | orderly shutdown's last step: enter S5 (soft off) |
|
||||
| service → subscriber | one event per kind | a published `Notice`, `ipc_send`t as a buffered packet (never sent *to* the service) |
|
||||
|
||||
`subscribe` is not one of this protocol's own verbs: a synchronous call whose
|
||||
attached capability is the subscriber's endpoint is exactly what the envelope's
|
||||
reserved `subscribe` means everywhere, so power adopts it wholesale. And no
|
||||
packet carries a version — the reserved `describe` verb is the version handshake,
|
||||
asked once at connect time rather than out of every packet's budget.
|
||||
|
||||
Events are published, not polled: like the input service, the service holds
|
||||
subscriber endpoints as capabilities and `ipc_send`s each event as a buffered
|
||||
message, so a slow or dead subscriber can never wedge the source. The event
|
||||
packet, so a slow or dead subscriber can never wedge the source. The table, the
|
||||
reserved `subscribe`/`unsubscribe` verbs and the fan-out are the **service
|
||||
harness's** (`service.Subscribers`), shared with input and the device manager, so
|
||||
the acpi service's own code is the ACPI half only — and a subscriber that dies is
|
||||
now swept on its exit notification, where before this service had no sweep at
|
||||
all. **The kind is the packet's operation** — one declared event per named kind, exactly as the
|
||||
input service delivers one per device class — so a subscriber reads *what
|
||||
happened* out of the header rather than out of a tag inside the payload. The
|
||||
vocabulary is hardware-neutral:
|
||||
|
||||
- `power_button` — the button was pressed (a fixed ACPI event on x86).
|
||||
- `lid`, `ac`, `battery` — the named GPE-driven events.
|
||||
- `notify` — a device notification that maps to none of the above; its `code`
|
||||
(the ACPI `Notify` argument) and the notifying device's `hid` say which device
|
||||
and what happened.
|
||||
- `power_button` (event 16) — the button was pressed (a fixed ACPI event on x86).
|
||||
- `lid` (17), `ac` (18), `battery` (19) — the named GPE-driven events.
|
||||
- `notify` (20) — a device notification that maps to none of the above; its
|
||||
`code` (the ACPI `Notify` argument) and the notifying device's `hid` say which
|
||||
device and what happened.
|
||||
|
||||
An `EventMessage` carries the `event` tag plus `code` and an 8-byte `hid`, so a
|
||||
generic `notify` is fully described without a second round trip.
|
||||
The payload every one of them carries is a `Notice`: `code` plus an 8-byte `hid`,
|
||||
so a generic `notify` is fully described without a second round trip, and the
|
||||
four named kinds leave both fields zero because the verb already said it all.
|
||||
|
||||
**`shutdown` is authority, not information.** It is the only operation that
|
||||
*does* something irreversible, so it is gated: the contract is that only init
|
||||
@@ -58,7 +73,10 @@ sequence over everything else. The acpi service implements this as a **soft
|
||||
gate** — it honors `shutdown` only from a process that is a *subscriber*, and
|
||||
init is the one subscriber. That stands in for "only the system supervisor may
|
||||
power off" without hard-coding a pid, so it still holds under tests where PID 1
|
||||
is not init.
|
||||
is not init. The question is asked of the harness's table now
|
||||
(`Subscribers.has(sender)`), which is why the harness exposes it: the gate is
|
||||
unchanged, including the badge being the whole of it — the badge is
|
||||
kernel-stamped, so nothing inside a packet can claim to be init.
|
||||
|
||||
## Orderly shutdown
|
||||
|
||||
|
||||
@@ -188,9 +188,15 @@ zombie state or privileged snooping:
|
||||
state by all along is the id the exit event carries.
|
||||
|
||||
Subscription, not broadcast-to-everyone: only processes that asked receive
|
||||
events, the kernel keeps a bounded subscriber table, and delivery is the same
|
||||
non-blocking coalescing notification as everything else — a dying process never
|
||||
waits on its mourners. Subscribing is ungated, like `process_enumerate`: what is
|
||||
events, the kernel keeps a bounded subscriber table (sixteen — a normal boot
|
||||
already fields six, since this is what *every* provider with per-client state
|
||||
releases on), and delivery is the same non-blocking coalescing notification as
|
||||
everything else — a dying process never waits on its mourners.
|
||||
A service does not usually write the sweep itself: the shared service harness
|
||||
subscribes for it and drops a dead task's event subscriptions
|
||||
(`service.Subscribers`), and a provider adds its own handler only for state the
|
||||
harness knows nothing about — open files, layers, device tokens.
|
||||
Subscribing is ungated, like `process_enumerate`: what is
|
||||
running (and dying) is not a secret between cooperating processes. Subscribers
|
||||
do not receive the exit reason — the filesystem server does not care *why*
|
||||
the client died.
|
||||
@@ -285,6 +291,10 @@ callbacks (`on_terminate`, `on_reload`) for programs that want defaults.
|
||||
|
||||
`service` owns the `replyWait` loop and folds every event source — signals,
|
||||
child exits, protocol messages — into callbacks, with the vocabulary's defaults:
|
||||
it also owns the **subscriber side** of any protocol that declares events
|
||||
(`service.Subscribers`: the table, the reserved `subscribe`/`unsubscribe` verbs,
|
||||
the fan-out, and the sweep on a subscriber's published exit), so every event
|
||||
stream in the system behaves identically.
|
||||
`terminate` returns from the loop (clean exit), the common `ping` is answered automatically,
|
||||
`reload` is ignored unless overridden. One loop, no locking, nothing reentrant. A
|
||||
service author writes domain logic; the lifecycle contract is satisfied by the
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
# The protocol namespace
|
||||
|
||||
*Design, agreed 2026-07-31. Supersedes the `ServiceId` registry. P1–P3 of the
|
||||
migration plan at the end have landed (the envelope, the registry and the
|
||||
`ServiceId` flag-day, and restriction stage one); P4 and P5 are the remaining
|
||||
work list.*
|
||||
|
||||
How a program finds, connects to, and is restricted from the things it talks to.
|
||||
Three ideas, kept deliberately separate:
|
||||
|
||||
1. **Naming** — a path under `/protocol` names a *contract*, not a service.
|
||||
2. **Access** — resolving that path yields an endpoint *capability*; what a process
|
||||
cannot resolve, it cannot reach.
|
||||
3. **Transport** — unchanged: packets over channels, moved by whichever
|
||||
transport the channel rides (kernel-ipc first).
|
||||
This document is layers **L3** (the namespace) and **L2** (the protocol
|
||||
and its envelope) of the communication stack;
|
||||
[communication.md](communication.md) owns the model and the vocabulary
|
||||
(*protocol* the language, *channel* the conversation, *packet* the
|
||||
transmitted unit, *signal* the payload-less poke, *transport* the
|
||||
replaceable mechanism), and
|
||||
[ipc.md](../device-driver-development/ipc.md) is the first transport.
|
||||
|
||||
## Why ServiceId has to go
|
||||
|
||||
Today a service calls `ipc_register(service_id, endpoint)` and a client calls
|
||||
`ipc_lookup(service_id)`, where `ServiceId` is a compile-time enum in `abi.zig`
|
||||
backed by a flat 16-slot table in the kernel. Three defects, in rising order:
|
||||
|
||||
- **Static.** The id space is baked into the ABI at compile time. A third-party
|
||||
program can never introduce a service; the one place danos is *less* dynamic
|
||||
than its own design.
|
||||
- **Ungated.** `ipc_register` is callable by any process and *replaces* an
|
||||
existing registration. Any process can hijack `.fat` or `.display` and
|
||||
impersonate it. `ipc_lookup` is equally ambient.
|
||||
- **Unrestrictable.** Because lookup is a syscall available to everyone, there is
|
||||
no point at which "this process may not talk to the display" can be enforced.
|
||||
Any future file-access restriction would be bypassable by speaking to the FAT
|
||||
server directly.
|
||||
|
||||
## Naming: contracts, not services
|
||||
|
||||
`/protocol/<name>` names a protocol — the contract a conversation follows — and
|
||||
resolving it connects you to whatever process currently provides that contract.
|
||||
The client never cared *which* binary answers; it cares that its messages are
|
||||
understood. Naming the contract makes that explicit, and buys:
|
||||
|
||||
- **Swappable providers.** Replace the display server; `/protocol/display`
|
||||
routes to the new one; clients notice nothing.
|
||||
- **Test fakes.** Spawn a program whose namespace wires `/protocol/display` to a
|
||||
mock. The name promises the protocol; the mock speaks it.
|
||||
- **One vocabulary.** The names mirror `library/protocol/`: a program imports
|
||||
the `display-protocol` module, then opens `/protocol/display`. What you
|
||||
compiled against and what you ask the namespace for are the same word.
|
||||
|
||||
A leaf names one contract — kebab-case, full words, matching the
|
||||
`library/protocol/` module that defines its wire format — and related
|
||||
contracts group into directories: `/protocol/networking/ip`,
|
||||
`/protocol/networking/bluetooth`. Directories organize *contracts only*;
|
||||
they never encode addressing (see below), so a directory appears because a
|
||||
domain has several contracts, never because hardware multiplied. The module
|
||||
tree mirrors the namespace (`library/protocol/networking/ip` ↔
|
||||
`/protocol/networking/ip`), and registrar grants scope naturally to subtrees
|
||||
— an application installed at `/applications/foo` can be granted
|
||||
`/protocol/applications/foo/...` and nothing above it. `/protocol` is
|
||||
top level, beside `/system` and `/applications`, because the boundary it names
|
||||
is spoken on both sides: applications talk to protocols as much as the OS does
|
||||
(see [file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md)).
|
||||
|
||||
**Addressing lives inside the protocol, never in the path.** Which volume, which
|
||||
layer, which input device — that is a destination field in the messages, the way
|
||||
TCP carries a destination address, and the way danos protocols already work (the
|
||||
display protocol multiplexes layer ids; the vfs protocol addresses node ids).
|
||||
The namespace answers exactly one question — *may this process speak this
|
||||
protocol at all* — so `/protocol/block` is one name no matter how many disks are
|
||||
attached. The source address is never in the message either: it is the IPC
|
||||
badge, stamped by the kernel per message, unforgeable — a property TCP's source
|
||||
address does not have.
|
||||
|
||||
`/system/devices` (the device inventory) stays purely informational: facts for
|
||||
diagnosis, never a routing mechanism. Unix conflated the two in `/dev`; danos
|
||||
does not. You *read about* hardware in `/system/devices`; you *talk to* it
|
||||
through `/protocol`.
|
||||
|
||||
## Resolution: a protocol node in the VFS
|
||||
|
||||
The kernel VFS router already does the hard part: `fs_resolve` matches a mount
|
||||
prefix and installs the backend's endpoint capability in the caller's handle
|
||||
table. The registry is just a backend mounted at `/protocol` — ring 3, like FAT.
|
||||
Connecting is a normal vfs-protocol `open` with one twist in the reply:
|
||||
|
||||
```
|
||||
client kernel router registry backend
|
||||
│ fs_resolve("/protocol/display") │
|
||||
│──────────────────────────▶│ prefix match: /protocol │
|
||||
│◀── registry endpoint ─────│ (capability installed) │
|
||||
│ vfs open("display") ──────────────────────────────────────▶│
|
||||
│◀───────────────── Reply + capability = provider endpoint ──│
|
||||
│ ipc_call(provider, display-protocol messages...) │
|
||||
```
|
||||
|
||||
Both capability moves use machinery the kernel already has: request-direction
|
||||
and reply-direction `send_cap` on `call`/`replyWait`. The vfs protocol needs two
|
||||
additions, both append-only:
|
||||
|
||||
- `NodeKind.protocol` — a node that names a contract; its `open` establishes
|
||||
a **channel** (delivered as an endpoint capability) instead of returning a
|
||||
file id. The node is the protocol, the channel is the conversation, and the
|
||||
addressing inside the packets decides where within the provider each one
|
||||
lands. `readdir` over `/protocol` lists protocol nodes like any others, so
|
||||
the tree stays browsable for diagnosis.
|
||||
- The convention that an `open` reply may carry a capability. File backends
|
||||
(FAT) never use it; synthetic backends (the registry, later the device
|
||||
inventory) do.
|
||||
|
||||
The path lookup happens once, at connect time. The hot path — `ipc_call` on the
|
||||
cached endpoint — is untouched. A provider crash turns the cached endpoint dead
|
||||
(`-EPEER`), and the client's recovery is to re-resolve: the restart story falls
|
||||
out of the naming layer for free.
|
||||
|
||||
## Registration: the registrar, held by init
|
||||
|
||||
The registry backend is **init**. It is already PID 1, already spawns every
|
||||
service from its manifest, and already holds the supervision link to each — it
|
||||
is the process that *knows* which binary is which. (If init grows
|
||||
uncomfortable, the same design lifts into a dedicated registry service that
|
||||
init spawns first and delegates to; nothing below changes.)
|
||||
|
||||
- **Binding.** A service creates its endpoint and sends the registry a `bind`
|
||||
request with the protocol name as payload and the endpoint attached as the
|
||||
call's capability.
|
||||
- **Authorization.** Init's manifest gains a column: the protocols each spawned
|
||||
binary may bind. A `bind` from any process not granted that name is refused
|
||||
(`-EPERM`) — the badge identifies the caller, the supervision records map
|
||||
badge to binary. This is the registrar authority; it never leaves init.
|
||||
- **Collision is an error.** A name already bound refuses a second bind — never
|
||||
last-writer-wins. When a provider dies, init (its supervisor) unbinds its
|
||||
names; the restarted instance binds again.
|
||||
- **Provenance.** The registry records name → task id → binary path, so a
|
||||
diagnostic listing answers "who serves this?" at a glance:
|
||||
|
||||
```
|
||||
/protocol/display pid 12 /system/services/display
|
||||
/protocol/input pid 7 /system/services/input
|
||||
```
|
||||
|
||||
`ipc_register` and `ipc_lookup` retire; the `ServiceId` enum leaves `abi.zig`.
|
||||
The kernel keeps one residual rule: `/protocol` becomes a reserved prefix like
|
||||
`/system` — `fs_mount` refuses to shadow it, and init's boot-time mount is the
|
||||
only one it will ever hold. (Full gating of `fs_mount` is a separate item on
|
||||
the security track; the reserved prefix closes the hole for this namespace
|
||||
without waiting for it.)
|
||||
|
||||
## Restriction: per-process namespaces, not ACLs
|
||||
|
||||
danos has no users and no principals, deliberately. Restriction is therefore
|
||||
**delegation**: what a process may open is decided by whoever spawned it, and
|
||||
enforcement is absence — a protocol you cannot resolve does not exist for you.
|
||||
"Permission denied" and "not found" are the same answer, which is the same
|
||||
discipline the device layer already follows: the claim is the capability; here,
|
||||
the resolvable name is the capability.
|
||||
|
||||
Two stages, deliberately ordered so the useful half lands first:
|
||||
|
||||
**Stage one — the registry filters by badge.** Init is both the spawner and the
|
||||
registry, so its manifest already knows which binary may *open* which protocols
|
||||
(a second manifest column, beside the bind grants). An `open` from a process
|
||||
whose binary is not granted that protocol is refused. No new kernel mechanism
|
||||
at all; the display driver's view can be narrowed to nothing, a future
|
||||
downloaded application's to `display` and `input`, today.
|
||||
|
||||
**Stage two — spawn passes the namespace.** `spawn` gains an initial
|
||||
capability: the child's connection to *its* registry view, chosen by the
|
||||
spawner. A newly spawned process starts with an empty handle table and this one
|
||||
handle — its world is whatever its parent wired in. This removes the last
|
||||
ambient reach (`fs_resolve` finding `/protocol` globally), lets any supervisor
|
||||
— not just init — narrow or fake a child's view (an application launcher
|
||||
granting an app only what its manifest declares; a test harness substituting
|
||||
every provider), and composes down the supervision tree. Stage one's manifest
|
||||
column becomes the *content* of the view init builds, so nothing is thrown
|
||||
away.
|
||||
|
||||
### A worked example: the microphone prompt
|
||||
|
||||
The scenario stage two exists for: an application opens
|
||||
`/protocol/audio-input`, and the user should be asked. The supervisor is an
|
||||
ordinary user process — an application launcher — and the flow needs no new
|
||||
security concepts:
|
||||
|
||||
1. The launcher spawned the app with a namespace channel that terminates at
|
||||
**the launcher itself**. The app's whole world is a conversation with its
|
||||
supervisor.
|
||||
2. The app's `open("audio-input")` packet lands in the launcher,
|
||||
badge-stamped. The launcher spawned the app, so badge → binary path
|
||||
(`/applications/foo`) is its own supervision record — "remember my choice"
|
||||
needs no identity system.
|
||||
3. Grant unknown → the launcher parks the request and shows a prompt (it is a
|
||||
user process with display access; init never does UI). Blocking an open on
|
||||
a human is architecturally fine: opens are connect-time, never hot-path.
|
||||
4. **Yes** → the launcher opens `/protocol/audio-input` in *its own*
|
||||
namespace and attaches the resulting channel to the parked reply. The app
|
||||
cannot tell a prompt happened — a consented open is indistinguishable from
|
||||
a direct one, merely slower.
|
||||
5. **No** → refuse the open, indistinguishable from "no such protocol" — or
|
||||
hand the app a **fake**: a silence-generating provider. The test-fake
|
||||
mechanism doubles as a privacy feature.
|
||||
|
||||
The capability discipline holds throughout: the launcher can only grant what
|
||||
it holds — if init never gave the launcher `audio-input`, no prompt can
|
||||
conjure it. Consent is delegation flowing down the supervision tree, never a
|
||||
global ACL edit. And the provider still sees the app's badge on every packet,
|
||||
so a coarser second check at the audio service remains possible.
|
||||
|
||||
Two mechanical requirements this scenario pins on stage two:
|
||||
|
||||
- **Parked replies.** A prompt takes seconds, and the service loop holds one
|
||||
outstanding reply today — the launcher must park request A, keep serving B
|
||||
and C, and reply to A later (by badge). The kernel already tracks owed
|
||||
replies (that is how death delivers `-EPEER`); multiple parked replies is
|
||||
the extension, in the harness and, if needed, the kernel.
|
||||
- **Granted channels are dedicated, hence revocable.** Once the app holds a
|
||||
channel capability, nobody reaches into its handle table — so a
|
||||
prompt-granted channel must be one that can be *killed*: a dedicated
|
||||
endpoint pair (or per-client session at the provider) whose death turns
|
||||
the app's capability into `-EPEER`. Revoking microphone access is then
|
||||
killing that channel, using machinery that already exists.
|
||||
|
||||
One adjacent problem, named and deferred: **trusted UI**. The prompt is only
|
||||
meaningful if the app cannot draw a convincing fake or overlay the real one —
|
||||
a display-layer question (a reserved surface for the supervisor chain), owned
|
||||
by the display track, not this one.
|
||||
|
||||
Fine-grained restriction *within* a protocol (this process may use volume A but
|
||||
not volume B) is not the namespace's job. The capability-shaped answer, when it
|
||||
is needed: the supervisor pre-opens a connection scoped to one target and passes
|
||||
that connection to the child, which never opens `/protocol/block` at all.
|
||||
Delegation again, not ACLs.
|
||||
|
||||
## The envelope: one addressing scheme for every protocol
|
||||
|
||||
Every protocol module today hand-rolls its `Request`/`Reply` with an
|
||||
`operation` first field. That convention becomes a library, so addressing is
|
||||
uniform and the rules are enforced by construction rather than by review. New
|
||||
module: **`library/protocol/envelope`** (the one protocol-layer module that is
|
||||
not itself a protocol).
|
||||
|
||||
```zig
|
||||
/// Every packet a danos protocol transmits begins with this header.
|
||||
pub const Header = extern struct {
|
||||
operation: u32, // the verb; values 0..15 are reserved universal verbs
|
||||
_padding: u32 = 0,
|
||||
/// Object addressing, never party addressing: which of the peer's
|
||||
/// objects this packet operates on — a volume, layer, node, device.
|
||||
/// 0 addresses the provider itself. Parties are addressed by the
|
||||
/// channel; the protocol defines target's meaning; the field's place
|
||||
/// and width are universal.
|
||||
target: u64 = 0,
|
||||
};
|
||||
|
||||
/// Reserved verbs, answered by every provider.
|
||||
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;
|
||||
|
||||
/// Every reply begins with this.
|
||||
pub const Status = extern struct {
|
||||
status: i32, // 0 or a negative errno
|
||||
_padding: u32 = 0,
|
||||
len: u32 = 0, // payload bytes following the header
|
||||
_padding2: u32 = 0,
|
||||
};
|
||||
```
|
||||
|
||||
A protocol is then *defined through* the envelope, not beside it:
|
||||
|
||||
```zig
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "display",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "configure_layer", .request = ConfigureLayer, .reply = void },
|
||||
.{ .name = "blit", .request = Blit, .reply = void },
|
||||
...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`Define` is comptime and is where the enforcement lives:
|
||||
|
||||
- verbs are numbered automatically from `first_protocol_operation`, so no
|
||||
protocol can collide with the reserved range;
|
||||
- every packet is size-checked at compile time against the kernel-ipc floor
|
||||
— `packet_maximum` (256) for request/reply, `post_maximum` (64) for event
|
||||
packets. Ceilings are transport properties
|
||||
([communication.md](communication.md)); the floor is what every protocol
|
||||
may assume on any transport. The errors that today surface as runtime
|
||||
truncation become compile errors, and packets-never-fragment is enforced
|
||||
at the source;
|
||||
- the generated type carries encode/decode helpers and a provider-side dispatch
|
||||
table, so a provider answers `describe` automatically and unknown operations
|
||||
with `-ENOSYS` uniformly;
|
||||
- the service harness (`library/kernel/service.zig`) accepts the generated
|
||||
dispatch type, which is what makes the envelope *enforced*: a protocol that
|
||||
bypasses `Define` does not plug into the harness.
|
||||
|
||||
Universal conventions that ride on the reserved verbs:
|
||||
|
||||
- **`describe`** is the version handshake. Version lives in the handshake, not
|
||||
in every message — the 256-byte budget is too small to spend per call.
|
||||
- **`enumerate`** is how multi-target protocols expose their targets, and the
|
||||
standard `targets_changed` notification (a notify bit) tells subscribers to
|
||||
re-enumerate — arrival and removal of volumes, layers, devices all take the
|
||||
same shape. Hotplug fits the notification ring far better than a filesystem
|
||||
tree ever did.
|
||||
- **Source is the badge.** No protocol defines a "sender" field; the kernel's
|
||||
per-message badge is the only source identity, and providers key per-client
|
||||
state on it.
|
||||
|
||||
### Paths resolve once; integers do the work
|
||||
|
||||
A rule the envelope makes official: **a path appears in a conversation at most
|
||||
once — at resolve or open — and everything after it addresses integers.** The
|
||||
namespace resolves `/protocol/display` to an endpoint; a backend's `open`
|
||||
resolves a path payload to a node id; from then on every packet carries the
|
||||
integer in `target`. Integers compare in one instruction and fit the fixed
|
||||
header, and the 256-byte message budget never re-carries path strings on the
|
||||
hot path. This is already the system's shape — vfs node ids, display layer ids
|
||||
— and the envelope pins it as the required shape for every protocol.
|
||||
|
||||
Two integer identities, not to be confused:
|
||||
|
||||
- **An open handle** — what vfs `open` returns today: transient, meaningful
|
||||
only within one client's session with one provider, swept when the client
|
||||
exits. Cheap, and all a protocol usually needs. Handles must be **scoped per
|
||||
client** — validated against the badge, or drawn from a per-client id
|
||||
namespace. (Today the FAT server's node ids are guessable small integers
|
||||
honoured across clients; that hole closes with this rule.)
|
||||
- **A persistent node identity** — a unix inode number, stable across opens
|
||||
and renames. danos deliberately does not promise this, because FAT cannot
|
||||
deliver it: a FAT file's identity is its directory entry, and rename or
|
||||
truncation moves every candidate anchor. If a future filesystem or a cache
|
||||
layer needs stable identity, that is the backend's promise to make, never
|
||||
the protocol's assumption.
|
||||
|
||||
The five existing protocol modules (`vfs`, `display`, `input`, `power`,
|
||||
`block`, plus `scanout`, `usb-transfer`, `device-manager`) rebase onto the
|
||||
envelope during the migration flag-day. `input-protocol`'s subscribe/publish
|
||||
split and `vfs-protocol`'s node addressing both map cleanly (`node` and layer
|
||||
ids become `target`).
|
||||
|
||||
## Wiring: how conversations flow
|
||||
|
||||
The patterns below are channel-layer (L1) shapes; the delivery mechanics are
|
||||
the kernel-ipc transport's, described here because it is the transport every
|
||||
channel starts on. Kernel-ipc provides exactly three delivery shapes, and
|
||||
every one is unicast. An endpoint is a mailbox owned by one process — its
|
||||
creator receives; anyone holding its capability sends into it. That direction
|
||||
never reverses:
|
||||
|
||||
1. **Synchronous call** — request/reply. The kernel parks the caller and
|
||||
`replyWait` delivers the reply straight back, so the provider answers
|
||||
without holding any capability to the client. Badge-stamped, blocking, and
|
||||
the *only* shape that carries capabilities (in the request, and in the
|
||||
reply — which is how a reverse path is bootstrapped).
|
||||
2. **Asynchronous send** — an event packet pushed into the receiver's post
|
||||
ring, at most `post_maximum` (64) bytes, no reply owed, never blocks the
|
||||
sender. Strictly one-way: to be pushed to, you must first hand the pusher
|
||||
your endpoint.
|
||||
3. **Signals** — payload-less notification bits, below the packet layer,
|
||||
coalescing: "something changed, come look."
|
||||
|
||||
A bidirectional link is therefore always **a pair of endpoints**, one per
|
||||
direction, each delivered by cap-passing. Three conversation patterns are
|
||||
built from these, and the envelope names all three:
|
||||
|
||||
- **Request/response** — the synchronous call. The default, and the only
|
||||
place capabilities move.
|
||||
- **Event stream** — `subscribe` (a synchronous call whose attached
|
||||
capability is the subscriber's own endpoint), after which the provider
|
||||
pushes events asynchronously; `unsubscribe` or subscriber exit ends it.
|
||||
Listened-to, not blocked-on.
|
||||
- **Change signal** — a signal plus re-read: `targets_changed` →
|
||||
`enumerate`. For state whose truth lives with the provider.
|
||||
|
||||
**Broadcast is a provider pattern, never a kernel primitive.** The kernel
|
||||
does not know subscriber sets — a service does. The input service is the
|
||||
model: sources *publish* (a unicast call to the service), the service
|
||||
*broadcasts* (a fan-out loop of asynchronous sends over its subscriber list,
|
||||
so one dead subscriber can never stall the rest). One fan-out point per event
|
||||
domain, owned by the service that defines the event.
|
||||
|
||||
The harness owns the machinery: the subscriber table, the dead-subscriber
|
||||
sweep (via process-exit notifications), and the fan-out loop — all written by
|
||||
hand in `input.zig` today, lifted into the service harness so every protocol
|
||||
gets identical semantics. `Define` declares a protocol's events (`.events`),
|
||||
and each event type is checked against `post_maximum` at compile time,
|
||||
generalizing the assert `input-protocol` already carries.
|
||||
|
||||
**Event packets are droppable.** A slow subscriber's ring fills, and the
|
||||
provider must not block on it — so an event stream is a hint or a coalescing
|
||||
signal, never a ledger. Anything that must not be lost is either re-readable
|
||||
state (the change-signal pattern) or bulk data in shared memory with a
|
||||
packet as the doorbell, which is how the display path already works — the
|
||||
packets-never-fragment rule and this one are the same rule seen from two
|
||||
sides.
|
||||
|
||||
**Source direction (open point).** Today event sources are *clients*: an
|
||||
input driver resolves `/protocol/input` and delivers each event as a
|
||||
synchronous `publish` call — one capability, obtained by resolution, covers
|
||||
everything, and the badge tells the service exactly who each event came from.
|
||||
The inversion — the service subscribing to each driver — would require every
|
||||
driver to be individually discoverable and its endpoint ferried to the
|
||||
service, machinery whose payoff (the service choosing its sources) the
|
||||
namespace already provides more cheaply: only a process granted open on
|
||||
`/protocol/input` can publish into it. Sources stay clients for now;
|
||||
revisited at restriction stage two, when a supervisor can wire capabilities
|
||||
at spawn time.
|
||||
|
||||
## What this deliberately does not solve
|
||||
|
||||
The wider security track, for which this namespace is the foundation, not the
|
||||
whole:
|
||||
|
||||
- **File access restriction** — the point of the exercise. The same stage-two
|
||||
namespace mechanism extends from protocol names to file paths: the spawner
|
||||
decides which subtrees resolve. Designed separately once this lands.
|
||||
- `fs_mount` gating beyond the reserved prefixes; `system_spawn` gating;
|
||||
`klog_read` being world-readable; backends checking the badge on per-node
|
||||
operations (the FAT server honours node ids across clients today).
|
||||
- Kernel hardening items already noted in-tree: SMEP/SMAP and SYSRET
|
||||
canonical-RIP, now designed in [smep-smap.md](smep-smap.md).
|
||||
- Pipes/FIFOs for the POSIX layer — a byte-stream object *beside* message IPC,
|
||||
wanted by the Python track, unrelated to naming.
|
||||
- **Trusted UI** — a permission prompt an application cannot fake or overlay
|
||||
(see the microphone example). A display-track concern: the supervisor chain
|
||||
needs a reserved surface.
|
||||
|
||||
## Migration plan
|
||||
|
||||
Flag-day per phase, in the style of the DMA-capability conversion — no
|
||||
dual-stack periods, the QEMU suite green at each phase boundary.
|
||||
|
||||
**P1 — mechanics, no behavior change.** The `envelope` module with its comptime
|
||||
`Define`, unit tests; `NodeKind.protocol` and the open-reply-capability
|
||||
convention in `vfs-protocol`; existing protocols untouched.
|
||||
|
||||
**P2 — the registry.** Init serves `/protocol` (bind with manifest
|
||||
authorization, collision refusal, unbind on provider death, provenance);
|
||||
kernel reserves the `/protocol` prefix; every service converts from
|
||||
`ipc_register` to `bind`, every client from `ipc_lookup` to resolve-and-open;
|
||||
`ServiceId`, `ipc_register`, `ipc_lookup` deleted. Tests: unauthorized bind
|
||||
refused, collision refused, provider restart re-binds and a client re-resolves.
|
||||
|
||||
**P3 — restriction, stage one.** The open-grant column in init's manifest;
|
||||
registry refuses ungranted opens. Test: a fixture process denied a protocol its
|
||||
neighbour is granted.
|
||||
|
||||
**P4 — protocol rebase.** Existing protocol modules re-expressed through
|
||||
`Define`; providers move onto the generated dispatch; `describe`/`enumerate`
|
||||
answered everywhere; the conformance test fixture exercises the reserved verbs
|
||||
against every registered provider.
|
||||
|
||||
**P5 — restriction, stage two.** Spawn's initial capability; namespace views
|
||||
built by the spawner; ambient resolution of `/protocol` retired. Includes the
|
||||
two requirements the microphone example pins: **parked replies** (a
|
||||
supervisor parks an open, keeps serving, replies later by badge) and
|
||||
**dedicated, killable granted channels** (revocation = channel death →
|
||||
`-EPEER`). Scoped separately — it touches `spawn`, the loader contract, and
|
||||
every supervisor — and lands together with the file-path half of namespacing.
|
||||
|
||||
The unix-path migration ([file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md#migration))
|
||||
is independent of P1–P5 and can land before or after.
|
||||
@@ -0,0 +1,149 @@
|
||||
# SMEP and SMAP — supervisor-mode hardening
|
||||
|
||||
*Design, 2026-07-31. H1 (the copy layer), H2 (SMEP), HS (the SYSRET guard) and
|
||||
H3 (SMAP) have all landed; the track is complete. Companion to
|
||||
[protocol-namespace.md](protocol-namespace.md) on the security track — this is
|
||||
the hardware half; that is the namespace half.*
|
||||
|
||||
Two CR4 bits that make the CPU refuse the two things a kernel should never do
|
||||
with user memory:
|
||||
|
||||
- **SMEP** (Supervisor Mode Execution Prevention, CR4 bit 20): instruction
|
||||
fetch in ring 0 from a page whose U/S bit says *user* → #PF. Kills the
|
||||
classic ret2usr exploit shape — a kernel bug that redirects control flow
|
||||
can no longer land in attacker-prepared user code.
|
||||
- **SMAP** (Supervisor Mode Access Prevention, CR4 bit 21): data read/write
|
||||
in ring 0 to a user page → #PF, unless `EFLAGS.AC` is set. `stac`/`clac`
|
||||
open and close deliberate access windows; danos's design needs no windows
|
||||
at all (below).
|
||||
|
||||
Detection is CPUID leaf 7, subleaf 0, EBX bit 7 (SMEP) and bit 20 (SMAP).
|
||||
Both bits are per-core state: the BSP and every AP must set them.
|
||||
|
||||
## Why, in danos terms
|
||||
|
||||
Every syscall argument is an attacker-controlled integer, and several take
|
||||
pointers. A kernel bug that dereferences a crafted pointer reads, writes, or
|
||||
executes memory of the attacker's choosing — the exact bug class the
|
||||
isolation tracks exist to prevent. SMEP/SMAP turn that class from "silent
|
||||
compromise" into "immediate, attributable #PF with a kernel RIP in the log."
|
||||
|
||||
The second benefit matters as much as the first: **SMAP is a permanent
|
||||
tripwire.** Once it is on, any *future* syscall that touches user memory
|
||||
directly — instead of going through the checked copy layer — faults the
|
||||
first time the QEMU suite runs it. The discipline stops depending on review.
|
||||
|
||||
## Where danos already stands
|
||||
|
||||
The design is closer than it looks, because the IPC layer was built right:
|
||||
|
||||
- **The copy layer is already SMAP-proof.** `copyAcross` and `copyFromUser`
|
||||
(`system/kernel/ipc-synchronous.zig:305,333`) never dereference a user
|
||||
virtual address: they walk the page tables and move bytes through the
|
||||
physmap — kernel mappings throughout. SMAP cannot object.
|
||||
- **Syscall entry already clears AC.** `SFMASK = 0x4_0700` clears IF, TF,
|
||||
DF, **AC** on every `syscall`
|
||||
(`system/kernel/architecture/x86_64/per-cpu.zig:76`). The syscall path is
|
||||
SMAP-clean from day one.
|
||||
- **The interrupt path is not.** Hardware does *not* clear AC on IDT
|
||||
delivery, and ring 3 can set AC with `popfq` — so a hostile process could
|
||||
take an interrupt with AC=1 and have the handler run with SMAP suspended.
|
||||
`isr_common` (`system/kernel/architecture/x86_64/isr.s:366`) needs a
|
||||
`clac` beside its `swapgs`.
|
||||
- **CR4 today:** the BSP inherits firmware CR4 (no kernel write anywhere);
|
||||
APs set PAE/OSFXSR/OSXMMEXCPT in `trampoline.s:62-68`. Neither path sets
|
||||
SMEP/SMAP yet, and both must.
|
||||
- **The stragglers.** Nine syscalls still dereference user pointers raw
|
||||
after a bounds check — every one is a SMAP #PF waiting to happen, and
|
||||
every one is *already* a latent kernel fault today (an unmapped-but-in-
|
||||
range user page oopses the kernel instead of failing the call). The
|
||||
verified sweep of `system/kernel/process.zig` (2026-07-31; a
|
||||
whole-kernel `@ptrFromInt` audit found no user-address dereference
|
||||
outside this file):
|
||||
|
||||
| Syscall | Raw access | Direction |
|
||||
|---|---|---|
|
||||
| `system_spawn` | name + argument blob (`:972`, `:980`) | read |
|
||||
| `fs_resolve` | path in (`:1780`), result out (`:1797`) | read + write |
|
||||
| `fs_mount` | prefix + rewrite strings (`:1864`, `:1865`) | read |
|
||||
| `fs_unmount` | prefix string (`:1883`) | read |
|
||||
| `fs_node` | read buffer out (`:1820`) | write |
|
||||
| `debug_write` | message bytes (`:1700`; read twice — memcpy `:1710` and `log.append` `:1717`) | read |
|
||||
| `klog_read` | log bytes out (`:1741`) | write |
|
||||
| `klog_status` | status struct out (`:1758`) | write |
|
||||
| `process_enumerate` | descriptor array out (`:1132`) | write |
|
||||
| `device_enumerate` | descriptor array out (`:388`) | write |
|
||||
|
||||
For the write-direction rows the `@ptrFromInt` is in process.zig but the
|
||||
stores happen in callees (`scheduler.enumerate`
|
||||
`system/kernel/scheduler.zig:1209`, `devices_broker.enumerate`
|
||||
`devices-broker.zig:136`, `log.readAt` `log.zig:209`, the vfs node calls
|
||||
`vfs.zig:257/269/289`) — converting them means bounce buffers plus
|
||||
`copyToUser` around those calls, not just editing the process.zig lines.
|
||||
(Some paths already do it right — the futex word and the device-register
|
||||
descriptor go through `copyFromUser` (`:1087`, `:924`). The write
|
||||
direction has no public helper yet, but the mechanism exists:
|
||||
`copyAcross` with a kernel source is exactly how IPC replies reach user
|
||||
buffers, so `copyToUser` is a mechanical mirror.)
|
||||
|
||||
- **One known gap inside the copy layer itself:** the walk checks presence,
|
||||
not the leaf U/S and writable bits (`ipc-synchronous.zig:20-22` flags
|
||||
this). Today that is nearly moot — the user half contains only mappings
|
||||
the kernel itself created for that process — but it must close before
|
||||
shared or copy-on-write mappings exist, and closing it is part of making
|
||||
the copy layer the single trusted door.
|
||||
|
||||
## The plan
|
||||
|
||||
**H1 — copy discipline (the real work).** A `user-memory` kernel module:
|
||||
`copyFromUser` / `copyToUser` (the missing write direction) via the physmap
|
||||
walk, with U/S and writable leaf checks closing the in-tree TODO. Convert
|
||||
the nine stragglers. This fixes the latent unmapped-page kernel fault on
|
||||
its own — it is worth doing even if SMEP/SMAP never shipped. QEMU suite
|
||||
green; no behavior change visible to correct programs.
|
||||
|
||||
**H2 — SMEP.** A leaf-7 feature probe (the kernel has per-leaf `cpuid`
|
||||
helpers in `apic.zig` to generalize); set CR4.SMEP during per-CPU bring-up
|
||||
on BSP and APs — prefer the Zig-side per-CPU init over the trampoline
|
||||
assembly, so one code path covers every core and the trampoline stays
|
||||
minimal. Audit first that ring 0 never executes user-mapped pages: kernel
|
||||
text lives in the kernel half, `jump_to_user` is kernel code, and the AP
|
||||
trampoline page is kernel-mapped — expected clean, verify before flipping.
|
||||
|
||||
**H3 — SMAP.** Add `clac` at `isr_common` entry. `clac` is #UD on CPUs
|
||||
without SMAP, so the instruction is a 3-byte NOP in the image, patched to
|
||||
`clac` at boot when CPUID advertises SMAP (one-time patch beats a
|
||||
conditional branch in the hottest path in the kernel). Then set CR4.SMAP in
|
||||
the same per-CPU init. From this point the whole QEMU suite doubles as the
|
||||
enforcement test: any missed raw dereference is a vector-14 with a kernel
|
||||
RIP and a user CR2 — loud and attributable.
|
||||
|
||||
**H4 — keep it honest.** A line in the coding standards: kernel code
|
||||
touches user memory only through `user-memory`; there is no `stac` anywhere
|
||||
in the tree, and a PR that adds one is wrong by definition. SMAP enforces
|
||||
the rule mechanically at test time.
|
||||
|
||||
Feature-gating follows the timekeeping rule (work on any VM, real Intel,
|
||||
real AMD): both bits are probed, absence is logged and tolerated — like the
|
||||
IOMMU's fail-open, the machine still boots, just unhardened. QEMU: TCG
|
||||
implements both; KVM inherits the host (Intel Ivy Bridge+ for SMEP,
|
||||
Broadwell+ for SMAP; AMD Zen+ for both). The test images should run with
|
||||
`-cpu max` so the suite always exercises the enabled paths.
|
||||
|
||||
## Adjacent, deliberately separate
|
||||
|
||||
- **SYSRET canonical-RIP hardening** (`isr.s:192-194` documents it): a
|
||||
non-canonical return RIP makes `sysretq` #GP *in ring 0* on Intel. Same
|
||||
hardening bucket, independent fix (validate RCX before `sysretq`, fall
|
||||
back to `iretq`), should ride the same branch as H2/H3 but is not
|
||||
SMEP/SMAP.
|
||||
**Status — landed 2026-08-01 (HS).** The syscall exit sign-extends the
|
||||
return RIP from bit 47 (danos is 4-level only; nothing sets CR4.LA57) and
|
||||
falls back to `iretq` when that changes it, counting each refusal for the
|
||||
`sysret-canonical` case. Ring 3 could reach it: `syscall` as the last two
|
||||
bytes of the last canonical page returns to `user_half_end`.
|
||||
- **KPTI / Meltdown-class leaks are out of scope.** SMEP/SMAP police
|
||||
architectural accesses, not speculative ones. danos runs one kernel
|
||||
mapping in every address space and accepts that on affected hardware;
|
||||
revisit only if the threat model ever includes hostile native code on
|
||||
shared machines.
|
||||
@@ -11,7 +11,7 @@ sequential pass and hands the bytes to the kernel unmodified.
|
||||
|
||||
The capsule is a *performance artifact*, not a source of truth. The boot
|
||||
volume's `/system` and `/test` file trees remain the canonical layout (see
|
||||
[danos-file-system-hierarchy-FSH.md](../file-system-development/danos-file-system-hierarchy-FSH.md));
|
||||
[file-system-hierarchy.md](../file-system-development/file-system-hierarchy.md));
|
||||
the capsule is a pre-baked snapshot of the same binaries, derived from the same
|
||||
build graph, so the running system is identical whether the loader read the
|
||||
capsule or walked the tree.
|
||||
@@ -36,11 +36,11 @@ so it need be no fancier. Little-endian throughout:
|
||||
|
||||
```
|
||||
Header magic: u32 = "DNR2" (0x32524E44), count: u32
|
||||
Entry × count name: [64]u8 (NUL-padded FHS path), offset: u64, len: u64
|
||||
Entry × count name: [64]u8 (NUL-padded hierarchy path), offset: u64, len: u64
|
||||
blobs... each entry's file bytes, at its offset within the image
|
||||
```
|
||||
|
||||
- **Names are full FHS paths** (`/system/services/init`), not basenames — that
|
||||
- **Names are full hierarchy paths** (`/system/services/init`), not basenames — that
|
||||
is what "v2" means. The 64-byte capacity matches `abi.maximum_process_name`,
|
||||
so a task named after its binary path is never truncated. Paths longer than
|
||||
63 bytes are a build error (`pack-system-image.py` rejects them).
|
||||
@@ -54,14 +54,14 @@ blobs... each entry's file bytes, at its offset within the image
|
||||
|
||||
## How it is built
|
||||
|
||||
`build.zig` maintains one `bundled` list — every user binary and its FHS home.
|
||||
`build.zig` maintains one `bundled` list — every user binary and its hierarchy home.
|
||||
Three artifacts are derived from that same list, in the same build graph, so
|
||||
they cannot drift apart:
|
||||
|
||||
1. **The tree**: each binary installed at its FHS path (`zig-out/system/...`
|
||||
1. **The tree**: each binary installed at its hierarchy path (`zig-out/system/...`
|
||||
and `zig-out/test/...`, mirrored onto the FAT boot volume by
|
||||
`tools/make-fat-image.py`).
|
||||
2. **The manifest** (`system/manifest`): the FHS path of every bundled binary,
|
||||
2. **The manifest** (`system/manifest`): the hierarchy path of every bundled binary,
|
||||
one per line — the loader's per-file fallback input.
|
||||
3. **The capsule**: `tools/pack-system-image.py` packs the same binaries into
|
||||
the v2 container, installed at `zig-out/boot/system.img` and placed on the
|
||||
@@ -103,7 +103,7 @@ the kernel (`kernel.zig`) then publishes the same bytes twice, to two
|
||||
consumers:
|
||||
|
||||
- **The process layer** (`process.zig`): `system_spawn` looks binaries up in
|
||||
the ramdisk via `Reader.find` — exact FHS path, or unique basename for
|
||||
the ramdisk via `Reader.find` — exact hierarchy path, or unique basename for
|
||||
pre-path callers — and loads them as fresh ring-3 processes. The stored path
|
||||
becomes the task's name.
|
||||
- **The VFS root** (`vfs.zig`, `setInitialRamdisk`): the image is mounted as
|
||||
@@ -111,7 +111,7 @@ consumers:
|
||||
paths, so `/system` and, when the fixtures are bundled, `/test`. Directory
|
||||
nodes are derived from the entry paths (the unique parents), so the trees
|
||||
are listable and their files readable over the normal VFS protocol — the
|
||||
FHS boot tree every process sees comes straight out of the capsule bytes.
|
||||
boot tree every process sees comes straight out of the capsule bytes.
|
||||
|
||||
The image is never copied after the handoff and never mutated: the initrd is
|
||||
immutable, which is what makes the VFS's node serving lock-free.
|
||||
|
||||
@@ -27,7 +27,7 @@ packages whose build.zig calls `build_support.userBinary` (with `.threaded =
|
||||
true` where a binary spawns threads) and get packed into the initial-ramdisk;
|
||||
new syscalls extend [abi.zig](../../system/abi.zig) `SystemCall` + a
|
||||
`library/kernel` wrapper; test services live beside the code they exercise and
|
||||
register a `ServiceId` if they must be looked up.
|
||||
bind a `/protocol/test/...` name if they must be reachable.
|
||||
|
||||
## How to verify along the way
|
||||
|
||||
|
||||
@@ -270,13 +270,15 @@ stays single-threaded and lean.
|
||||
- *Handles do not cross threads.* The handle table lives on the `Task`
|
||||
([scheduler.zig](../../system/kernel/scheduler.zig)), so a handle number is meaningful
|
||||
only to the thread that created it — thread A's endpoint handle `3` is not thread B's.
|
||||
A thread that needs to reach an endpoint another thread owns looks it up
|
||||
(`ipc.lookup(service)`) to install its **own** handle to the same underlying endpoint.
|
||||
This is how the display's mouse-listener thread reaches the compositor loop's endpoint
|
||||
to poke it awake (docs/display.md).
|
||||
A thread that needs to reach an endpoint another thread owns opens the name
|
||||
(`channel.openEndpoint("display")`) to install its **own** handle to the same
|
||||
underlying endpoint — an ordinary client open, with no special mechanism for the
|
||||
fact that the provider happens to be this process. This is how the display's
|
||||
mouse-listener thread reaches the compositor loop's endpoint to poke it awake
|
||||
(docs/display.md).
|
||||
- *IPC syscalls that touch shared kernel state now serialize under the big kernel lock.*
|
||||
`create_ipc_endpoint`/`ipc_register`/`ipc_lookup` allocate from the kernel heap and
|
||||
mutate the global service registry, endpoint refcounts, and handle tables. Those paths
|
||||
`create_ipc_endpoint` allocates from the kernel heap and
|
||||
mutates endpoint refcounts and handle tables. Those paths
|
||||
were unlocked because a single-threaded process could not race itself; a multi-threaded
|
||||
one can, from two cores at once. They now take `sync.enter()` like `call`/`reply_wait`/
|
||||
`send` already did — the kernel heap has no lock of its own (heap.zig: "every kernel
|
||||
|
||||
@@ -137,15 +137,15 @@ Grouped as `abi.zig` groups them:
|
||||
| process | `danos_exit`, `danos_yield`, `danos_sleep`, `danos_spawn`, `danos_process_enumerate`, `danos_process_kill`, `danos_process_exit_reason`, `danos_process_subscribe`, `danos_process_signal`, `danos_signal_bind` |
|
||||
| threads | `danos_thread_spawn`, `danos_thread_exit`, `danos_current_core`, `danos_futex_wait`, `danos_futex_wake`, `danos_thread_self`, `danos_thread_join`, `danos_set_thread_pointer` |
|
||||
| memory | `danos_mmap`, `danos_munmap`, `danos_dma_alloc`, `danos_dma_free`, `danos_shared_memory_create`, `danos_shared_memory_map`, `danos_shared_memory_physical` |
|
||||
| ipc | `danos_endpoint_create`, `danos_ipc_register`, `danos_ipc_lookup`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` |
|
||||
| ipc | `danos_endpoint_create`, `danos_ipc_call`, `danos_ipc_reply_wait`, `danos_ipc_send` (naming is not a syscall: a provider binds its contract at the registry and a client resolves `/protocol/<name>` — see [protocol-namespace.md](protocol-namespace.md)) |
|
||||
| devices | `danos_device_enumerate`, `danos_device_claim`, `danos_device_register`, `danos_mmio_map`, `danos_irq_bind`, `danos_irq_ack`, `danos_msi_bind`, `danos_io_read`, `danos_io_write` |
|
||||
| time | `danos_clock`, `danos_wall_clock`, `danos_timer_bind` |
|
||||
| diagnostics | `danos_debug_write` (leveled, kernel-stamped records), `danos_klog_read`, `danos_klog_status` |
|
||||
| filesystem naming | `danos_fs_resolve`, `danos_fs_node`, `danos_fs_mount`, `danos_fs_unmount` (naming only — file DATA still crosses the vfs-protocol IPC, see below) |
|
||||
|
||||
The constants that ride alongside the calls — mmap protection bits, DMA
|
||||
flags, notification badge bits, `ExitReason`, `Signal`, well-known service
|
||||
ids, `page_size`, the IPC message maximum — move to the public header too:
|
||||
flags, notification badge bits, `ExitReason`, `Signal`, `page_size`, the IPC
|
||||
message maximum — move to the public header too:
|
||||
they are wire values a Rust program needs verbatim. What stays private in
|
||||
`abi.zig` is exactly the thing the vDSO exists to hide: the `SystemCall`
|
||||
numbers and the trap convention.
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
# Python on danos: the milestone plan
|
||||
|
||||
The execution plan for [python-on-danos.md](python-on-danos.md). That note holds
|
||||
the *why* and the design decisions; this one slices the work into milestones with
|
||||
concrete deliverables, tests, and exit criteria. Milestones are numbered **P0–P5**
|
||||
(track-local — the global M-series stays with the driver/lifecycle tracks).
|
||||
|
||||
Dependencies at a glance:
|
||||
|
||||
```
|
||||
P0 toolchain + mini-libc ──┐
|
||||
P1 streams + console + seam ─┴─→ P2 CPython minimal ─→ P3 terminal + REPL
|
||||
│ │
|
||||
└─→ P4 danos module │
|
||||
+ Python service│
|
||||
P5 process control + shell ←─────────────────────────────────┘
|
||||
```
|
||||
|
||||
P0 and P1 are independent of each other and can proceed in parallel. P1 is shared
|
||||
work — it is also Zig self-hosting Phase 1 and the first three slices of
|
||||
[character-devices-and-tty.md](character-devices-and-tty.md).
|
||||
|
||||
## P0 — Toolchain + the C library compatibility layer
|
||||
|
||||
**Goal:** a C hello-world, cross-compiled on the host with `zig cc`, runs on danos.
|
||||
|
||||
Design and slicing live in
|
||||
[c-library-compatibility.md](c-library-compatibility.md): the **libdanos-c**
|
||||
sysroot (hand-written danos-native headers + `libc.a`) as a `library/c/` build
|
||||
package — pure computation (string, libm, `strtod`, the printf/scanf engines)
|
||||
lifted from a vendored, pinned musl subtree; the OS plumbing written in Zig over
|
||||
the `runtime` surface (re-targeting `runtime.os` when the Zig track authors it);
|
||||
`malloc` over danos `mmap`; a `crt0` bridging the danos entry shim to C `main`.
|
||||
Driven by `zig cc -target x86_64-freestanding-none -isystem` (the triple becomes
|
||||
`x86_64-danos` if the Zig fork lands first; nothing else changes).
|
||||
|
||||
Its five slices (sysroot-skeleton, fd-plumbing, malloc, stdio,
|
||||
mathematics-and-time) carry their own tests — host-side oracle suites for the
|
||||
computation layer, QEMU cases (`c-hello`, `c-file-io`, `c-stdio`, `c-time`) for
|
||||
the plumbing.
|
||||
|
||||
**Exit:** `c-hello` and `c-file-io` green in the QEMU suite; host computation
|
||||
tests green.
|
||||
|
||||
## P1 — Stream nodes, console, and the seam pieces
|
||||
|
||||
**Goal:** the shared Phase-1 surface exists: byte-stream stdio, cwd, environment,
|
||||
entropy. Design and slicing live in
|
||||
[character-devices-and-tty.md](character-devices-and-tty.md); this milestone is
|
||||
its slices 1–3 plus three small seam pieces:
|
||||
|
||||
- **cwd/chdir** — per-process current directory used by path resolution (the
|
||||
kernel already anchors a VFS root per `fs_resolve`; the cwd is the same idea,
|
||||
process-scoped, with `getcwd`/`chdir` exposed through `runtime` and the libc).
|
||||
- **Environment** — spawn carries an environment block; the SysV entry stack's
|
||||
`envp` slot ([sysv.md](os-development/sysv.md)) stops being empty; `getenv`
|
||||
reads it. An empty block stays valid.
|
||||
- **Entropy** — a kernel `entropy` syscall (RDSEED/RDRAND with a jitter fallback,
|
||||
mirroring the TSC-reliability posture of not trusting one CPU feature blindly);
|
||||
the libc exposes `getentropy`.
|
||||
|
||||
- **Tests.** QEMU: the character-device tests from the tty note (offsetless
|
||||
read/write, blocking read, cooked/raw control round-trip), plus `cwd-basics`
|
||||
(chdir + relative open), `env-roundtrip` (spawn with env, child reads it),
|
||||
`entropy-sane` (nonzero, changing, correct length).
|
||||
|
||||
**Exit:** a C program reads a cooked line from fd 0 and echoes it to fd 1 —
|
||||
injected key events in, bytes read back through the console's in-memory sink,
|
||||
all under QEMU with no hardware involved — and `getcwd`/`getenv`/`getentropy`
|
||||
return real answers.
|
||||
|
||||
## P2 — CPython, minimal configuration
|
||||
|
||||
**Goal:** `python -c 'print(2**100)'` runs on danos under QEMU.
|
||||
|
||||
- Pin **CPython 3.13.x**; vendor as `third-party/cpython/` or fetch via the build
|
||||
(decide with the build-packages conventions).
|
||||
- Host build-Python of the same version (`--with-build-python`).
|
||||
- `config.site` cache for the cross answers; `config.sub` patch so
|
||||
`x86_64-unknown-danos` parses; a small `configure`/`pyconfig` patch set kept as
|
||||
rebasable diffs, WASI-style.
|
||||
- `--disable-shared`; static `Modules/Setup`: `posix errno _io _codecs _weakref
|
||||
time math _stat _collections itertools _functools _locale _sre` plus what the
|
||||
interpreter core insists on; threadless build (WASI precedent).
|
||||
- `Lib/` on the FAT image under the hierarchy (e.g. `/system/python/lib`);
|
||||
`PYTHONHOME` set accordingly; `.pyc` written with **checked-hash
|
||||
invalidation** (FAT's 2-second mtime granularity makes mtime-based validation
|
||||
lie during fast edit-run cycles).
|
||||
- `PYTHONHASHSEED` pinned only if P1's entropy slipped — otherwise real
|
||||
hash randomization from day one.
|
||||
- **Tests.** QEMU: `python-expr` (the exit criterion), `python-file` (run a
|
||||
script from FAT, write a file, read it back), then a curated slice of CPython's
|
||||
own suite (`test_int`, `test_float`, `test_io`, `test_dict`) as a
|
||||
longer-running target — the suite is the porting harness.
|
||||
|
||||
**Exit:** the four QEMU cases green; the CPython test slice green or with a
|
||||
short, documented skip list.
|
||||
|
||||
## P3 — Terminal + REPL: the first real application
|
||||
|
||||
**Goal:** an interactive `python` REPL in a graphical danos terminal — the
|
||||
milestone demo for the OS.
|
||||
|
||||
- Depends on the display track's font rendering (its stated next step) — until
|
||||
that lands, the REPL is exercised end-to-end through the pseudo-device
|
||||
harness from P1+P2 (scripted input in, output read back), so P2's exit is
|
||||
never blocked on graphics; the graphical terminal is the *interactive* debut.
|
||||
- The terminal application: draws with the UI toolkit / display client, consumes
|
||||
keyboard `InputEvent`s, and — per the tty note's load-bearing decision —
|
||||
**serves the VFS stream protocol itself** to its children, reusing the console's
|
||||
line-discipline library. Spawns `python` with its endpoints as fd 0/1/2.
|
||||
- Raw mode + the control set give the REPL line editing; window-size control
|
||||
gives it wrapping.
|
||||
- **Tests.** QEMU: scripted terminal session (inject key events, assert rendered
|
||||
or captured output). Real-hardware smoke on the Intel box joins the existing
|
||||
checklist.
|
||||
|
||||
**Exit:** typing `2+2` into the terminal on the QEMU GPU target prints `4`.
|
||||
|
||||
## P4 — The `danos` extension module + a Python service
|
||||
|
||||
**Goal:** Python can speak danos: IPC, capabilities, spawn.
|
||||
|
||||
- The `danos` module, **written in Zig against `Python.h`**, statically linked
|
||||
via `Modules/Setup`: endpoints (create/send/receive), capability passing,
|
||||
spawn + exit-notification, and the service bootstrap (announce, supervision
|
||||
handshake) — the same surface Zig services use, re-exposed.
|
||||
- UI-toolkit bindings as a second module once the toolkit's API settles.
|
||||
- Prototype **one real service in Python** — policy-shaped, not data-plane
|
||||
(candidates: hot-plug policy, a settings service) — speaking an existing wire
|
||||
protocol, supervised by the device manager like any service.
|
||||
- **Tests.** QEMU: `python-ipc-echo` (Python service echoes over an endpoint, a
|
||||
Zig client asserts), plus the prototype service's own protocol test.
|
||||
|
||||
**Exit:** a Python process runs as a supervised danos service exchanging IPC
|
||||
with Zig peers.
|
||||
|
||||
## P5 — Process control, then the shell
|
||||
|
||||
**Goal:** danos can spawn arbitrary programs with arguments and pipes; a small
|
||||
Python shell uses it.
|
||||
|
||||
The kernel/VFS cluster a shell forces (any shell, any language):
|
||||
|
||||
- **exec-of-path** — spawn an arbitrary VFS path, not a named ramdisk binary;
|
||||
- **argv/envp** — carried through spawn onto the child's entry stack (env from
|
||||
P1, argv new);
|
||||
- **numeric exit status** — extend the exit record beyond the categorical
|
||||
`ExitReason` (the gotcha the Zig roadmap flagged: `WEXITSTATUS` must be real);
|
||||
- **fd inheritance + pipes** — a kernel or service pipe (a character device by
|
||||
the tty note's definition) and spawn-time fd mapping.
|
||||
|
||||
Then, in order: `subprocess` enabled in CPython (maps onto spawn + the
|
||||
exit-notification endpoint — no fork, Windows-style); a **small Python shell** (a
|
||||
few hundred lines over `subprocess` + the console: prompt, argv parsing, pipes,
|
||||
cwd) as the forcing function that reveals what job control actually needs.
|
||||
|
||||
**Explicitly deferred past P5:** the pthread subset over `thread_spawn`/futex,
|
||||
signals-in-libc via M17, termios job control (Ctrl-C to foreground child), and
|
||||
**xonsh** — which wants all three and is the arc's endpoint, not a milestone.
|
||||
|
||||
**Tests.** QEMU: `spawn-argv-exit` (child echoes argv, exits 42, parent sees
|
||||
42), `pipe-through` (parent → child → parent), `python-subprocess`, and a
|
||||
scripted shell session.
|
||||
|
||||
**Exit:** the Python shell runs `program | program` typed at the terminal and
|
||||
reports the exit status.
|
||||
|
||||
## Post-P5 outlook
|
||||
|
||||
Two tracks continue past this plan, each with its own design doc rather than a
|
||||
P-number here:
|
||||
|
||||
- **Dynamic libraries** ([dynamic-libraries.md](dynamic-libraries.md), D1–D4) —
|
||||
an application-layer facility (the OS stays static and lean): `dlopen` in the
|
||||
libc, then libffi + `ctypes` + loadable extension modules, then shared
|
||||
read-only mappings so N Python services hold one physical `libpython`.
|
||||
- **The full C compatibility layer**
|
||||
([c-library-compatibility.md](c-library-compatibility.md), stages 2–3) — the
|
||||
standing rule that every system capability ships with its C spelling, draining
|
||||
the absence table toward "portable C builds on danos"; `fork` is the one
|
||||
permanent exception.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos.md](python-on-danos.md) — the design note this executes.
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — P0's design.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — P1's design.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — shares P1; its fork makes P0's
|
||||
triple prettier but gates nothing here.
|
||||
- [os-development/process-management.md](os-development/process-management.md) —
|
||||
the spawn/exit surface P5 extends.
|
||||
@@ -0,0 +1,261 @@
|
||||
# Python on danos: the CPython milestone
|
||||
|
||||
A design note (not built yet) on bringing **CPython** to danos, compiled with the Zig
|
||||
toolchain (`zig cc`). Like [zig-self-hosting.md](zig-self-hosting.md), it is
|
||||
forward-looking: it sets a direction and the decisions that follow from it.
|
||||
|
||||
## Why Python, and why now
|
||||
|
||||
The Zig self-hosting road is gated on a compiler fork and a long std-library seam.
|
||||
Python is the **stop-gap that removes the wait**: a working CPython gives danos a way
|
||||
to write programs — services, tools, application prototypes — *without* the Zig
|
||||
compiler being self-hosted, and it brings the pure-Python package ecosystem along as
|
||||
a bonus. The intended division of labour:
|
||||
|
||||
- **Zig** — the kernel, drivers, and anything on a data plane (interrupt paths,
|
||||
DMA rings, block I/O). Unchanged.
|
||||
- **Python** — the control plane and the prototyping surface: services that are
|
||||
event loops over IPC, policy logic that changes often, application experiments,
|
||||
and eventually the shell.
|
||||
|
||||
Python is also the scripting language for the terminal-and-shell arc: the first
|
||||
real danos application is planned as a terminal, a terminal wants a shell, a shell
|
||||
wants a scripting language — and [xonsh](https://xon.sh) (a shell written in
|
||||
Python) marks where that road can end.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- **No drivers in Python.** Interrupt handling, ring management, and DMA stay in
|
||||
Zig. Python may *supervise and configure* drivers; it does not sit in their hot
|
||||
paths (interpreter overhead and garbage-collection pauses in an interrupt path
|
||||
are disqualifying).
|
||||
- **No dynamic loading during bring-up, no `pip`.** The whole arc here ships
|
||||
statically linked. Dynamic libraries are a real *later* milestone
|
||||
([dynamic-libraries.md](dynamic-libraries.md)) — an application-layer
|
||||
facility that unlocks `ctypes` and loadable extension modules; the operating
|
||||
system itself stays static and lean regardless (the size doctrine below).
|
||||
`pip` stays out either way until a networking track exists.
|
||||
- **No fork.** `os.fork` will not exist. This costs almost nothing (see "The
|
||||
spawn model fits").
|
||||
|
||||
## The realization that shapes everything: the compiler is not the obstacle
|
||||
|
||||
`zig cc` is a full Clang-based C cross-compiler, and CPython is portable C with
|
||||
official precedent for stranger targets than danos — the WASI port is upstream
|
||||
tier-2, and it runs **without fork, without dynamic loading, and without working
|
||||
threads**. Every "CPython can't possibly run there" objection has already been
|
||||
answered upstream by a target *more* constrained than danos.
|
||||
|
||||
What CPython actually needs is a **C environment**: headers and a `libc.a`. danos
|
||||
has neither — and that is the whole project. In the language of the Zig roadmap's
|
||||
three doors, this is the **door-2-shaped work** (the deferred "musl door"), not the
|
||||
`std.os.danos` seam: CPython never touches Zig's std.
|
||||
|
||||
### The same surface, a third time
|
||||
|
||||
The Zig roadmap observed that door 1 (`std.os.danos`) and door 2 (a libc) implement
|
||||
the *same* ~30 danos-facing operations at different layers. CPython consumes that
|
||||
identical surface through C spellings. So nothing here is throwaway: the
|
||||
danos-native operations backing `runtime.os` are the same ones the libc bottoms out
|
||||
in, and the gaps this track must close (stdio byte streams, cwd, environment,
|
||||
entropy) are **exactly the Phase-1 gaps the Zig roadmap already lists**. The two
|
||||
tracks share a road until Python forks off at "build the libc."
|
||||
|
||||
## Where danos stands: coverage vs. the gaps
|
||||
|
||||
Judged against the minimal CPython configuration (static, WASI-like):
|
||||
|
||||
| CPython need | danos today | Gap |
|
||||
|--------------|-------------|-----|
|
||||
| open/read/write/close/lseek, readdir | VFS + FAT via `runtime.fs` | none — wrap in C |
|
||||
| mkdir / unlink / rename / truncate | done (self-hosting Phase 2) | none |
|
||||
| stat with mtime | done (`wall_clock` + FAT mtime) | none |
|
||||
| mmap/munmap (object allocator) | native syscalls | none |
|
||||
| monotonic + wall clock | `clock` + `wall_clock` syscalls | none |
|
||||
| a place for `Lib/` | FAT boot image | none — better than WASI has it |
|
||||
| fork / exec | not needed (subprocess disabled at first) | — |
|
||||
| dynamic loading | not needed (static extension modules) | — |
|
||||
| getcwd / chdir | — | **missing** (shared with Zig Phase 1) |
|
||||
| environment variables | `Init` has no env | **missing** (can start empty) |
|
||||
| entropy | — | **missing** (hash seed; `PYTHONHASHSEED` pins it meanwhile) |
|
||||
| byte-stream stdin/stdout (fd 0/1/2) | `debug_write` out; structured `InputEvent` in | **missing** (shared with Zig Phase 1; the REPL needs it) |
|
||||
| signals | — | stubs suffice (WASI precedent); M17 signals-over-IPC maps on later |
|
||||
| threads | native `thread_spawn`/futex | build threadless first; a pthread subset later (xonsh needs it) |
|
||||
|
||||
The clustering repeats the Zig roadmap's: **files, memory, and time are done; the
|
||||
work is the C packaging plus the small seam pieces** (tty bytes, cwd, env, entropy).
|
||||
|
||||
## The libc decision: hand-rolled in Zig, computation lifted from musl
|
||||
|
||||
Two viable shapes were considered:
|
||||
|
||||
| Option | What it is | Verdict |
|
||||
|--------|-----------|---------|
|
||||
| **Mini-libc in Zig** | C-ABI-exporting Zig library over `runtime.os`/`runtime.fs`, shipped as headers + `libc.a`. | **Take this.** Reuses the danos-native surface directly; no Linux assumptions to fight. |
|
||||
| **Port musl** | Full musl with a danos syscall backend. | Defer, again. musl assumes Linux syscall semantics in places; heavier than the need. |
|
||||
|
||||
The trick that makes the mini-libc tractable: musl's `string/`, `math/` (libm —
|
||||
CPython needs essentially all of it), and number-conversion layers are **pure
|
||||
computation with no syscalls**. Lift those wholesale (MIT-licensed, designed to
|
||||
compile standalone) and hand-write only:
|
||||
|
||||
- the OS-facing bottom: fds, `mmap`, clocks, `exit`, `getcwd` — thin C-ABI wrappers
|
||||
over `runtime.os`;
|
||||
- a `FILE*` stdio layer (buffered, over the fd layer);
|
||||
- `malloc` over danos `mmap` (a simple allocator is fine; CPython does its own
|
||||
small-object arena management above it);
|
||||
- the headers (`stdio.h`, `stdlib.h`, `string.h`, `math.h`, `errno.h`, …).
|
||||
|
||||
Estimate: **100–150 functions**, of which the hard 40% (libm, string, printf/strtod
|
||||
cores) are lifted, not written. Correctness hot spots are `strtod`/`dtoa` (Python's
|
||||
float repr round-trips through them) — another reason to lift musl's, not improvise.
|
||||
|
||||
## C interop: static extension modules, not ctypes
|
||||
|
||||
"Python can interface with C libraries" is true on danos with one important
|
||||
correction: **`ctypes` does not work at first** — it is built on `dlopen` + libffi,
|
||||
both of which arrive only with the [dynamic-libraries](dynamic-libraries.md)
|
||||
milestone (D2). Until then the interop story is the other, older one:
|
||||
|
||||
- **Extension modules statically linked into the interpreter** via CPython's
|
||||
`Modules/Setup` mechanism (the standard route for embedded/static builds).
|
||||
- **Zig speaks C ABI natively**, so danos extension modules are written in Zig
|
||||
against `Python.h` — no C required. Two modules are planned from the start:
|
||||
- **`danos`** — the system module: endpoints, send/receive, capability passing,
|
||||
spawn, exit notification. This is what makes a Python *service* possible: an
|
||||
event loop over IPC, speaking the same wire protocols as Zig services.
|
||||
- **UI toolkit bindings** — the in-progress danos UI toolkit exposed to Python,
|
||||
so application prototypes drive real windows.
|
||||
|
||||
The package story follows: **pure-Python packages work** (unpack into
|
||||
`Lib/site-packages` on the FAT image); packages with C extensions must be
|
||||
cross-compiled and baked into the interpreter — a curated set chosen per image,
|
||||
not `pip install`. That is the honest shape of the stop-gap.
|
||||
|
||||
## The roadmap
|
||||
|
||||
### Phase 0 — Toolchain + libc bring-up
|
||||
|
||||
`zig cc -target x86_64-freestanding-none` plus `-isystem` the danos headers and the
|
||||
mini-libc archive. No compiler fork required — this track deliberately avoids the
|
||||
Zig roadmap's Phase-0 gate (if the fork lands first, the triple becomes a clean
|
||||
`x86_64-danos`; nothing else changes). Exit criterion: a **hello-world C program**
|
||||
compiles on the host and runs on danos, printing via the libc's `write`.
|
||||
|
||||
### Phase 1 — The shared seam pieces
|
||||
|
||||
The same list as Zig self-hosting Phase 1, closed once for both tracks:
|
||||
|
||||
- fd 0/1/2 as console **byte** streams (output exists as `debug_write`; input is a
|
||||
new small thing — cooked line input first, raw mode when the REPL wants editing);
|
||||
- `getcwd`/`chdir`;
|
||||
- environment variables (an empty block is a valid start);
|
||||
- an entropy syscall or service (until then, builds pin `PYTHONHASHSEED`).
|
||||
|
||||
### Phase 2 — Cross-compile CPython, minimal configuration
|
||||
|
||||
Pin one CPython release (3.13 — strongest WASI-era cross-compile support). The
|
||||
mechanics are well-trodden upstream since 3.11:
|
||||
|
||||
- a same-version **build-Python on the host** (`--with-build-python`);
|
||||
- a `config.site` cache answering what configure cannot probe cross
|
||||
(`ac_cv_file__dev_ptmx=no` and friends);
|
||||
- a `config.sub` patch so `x86_64-unknown-danos` parses;
|
||||
- `--disable-shared`, static `Modules/Setup` with a minimal module set
|
||||
(`posix`, `errno`, `_io`, `_codecs`, `time`, `math`, …);
|
||||
- `Lib/` shipped on the FAT image; `PYTHONHOME` pointed at it.
|
||||
|
||||
Exit criterion: `python -c 'print(2**100)'` runs on danos under QEMU.
|
||||
|
||||
### Phase 3 — Terminal + REPL: the first real application
|
||||
|
||||
Depends on the display track's font rendering (already its stated next step) and
|
||||
Phase 1's tty. A terminal emulator drawing a `python` REPL is the milestone demo:
|
||||
interactive, self-evidently real, and it needs **zero** process-control machinery.
|
||||
|
||||
### Phase 4 — The `danos` module and Python services
|
||||
|
||||
Write the `danos` extension module and the UI-toolkit bindings; prototype one real
|
||||
service in Python (a policy-shaped one — e.g. hot-plug policy or a settings
|
||||
service) speaking the existing IPC protocols. This is the payoff phase for
|
||||
"prototyping a service or application."
|
||||
|
||||
### Phase 5 — Process control, then the shell
|
||||
|
||||
The shell — any shell, in any language — forces the surface danos has deferred so
|
||||
far: **exec-of-path, argv/envp passing, numeric exit status (`WEXITSTATUS`, not the
|
||||
categorical `ExitReason`), fd inheritance, and pipes.** That is a kernel/VFS
|
||||
milestone cluster of its own. Then, in order:
|
||||
|
||||
1. `subprocess` enabled in CPython (maps onto danos spawn — see below);
|
||||
2. a **small Python shell** (a few hundred lines over `subprocess` + line input, no
|
||||
job control) — the forcing function that reveals which process-control pieces
|
||||
actually matter;
|
||||
3. **explicitly deferred:** a pthread subset over `thread_spawn`/futex
|
||||
(create/join/mutex/condition/thread-locals), signals via M17 signals-over-IPC,
|
||||
termios job control — and then **xonsh**, which wants all three.
|
||||
|
||||
### The spawn model fits
|
||||
|
||||
One genuinely good alignment: **CPython does not need fork.** `subprocess` maps
|
||||
cleanly onto a posix_spawn-style model — exactly what danos has — and the existing
|
||||
exit-notification-via-endpoint is a *better* fit for `Popen.wait` than Unix's
|
||||
`wait` semantics. `os.fork` simply won't exist, as on Windows, and almost nothing
|
||||
in practice cares.
|
||||
|
||||
## Risks and gotchas
|
||||
|
||||
- **Binary size — and the size doctrine that makes it acceptable.** danos's
|
||||
leanness mandate applies to the **operating system**: the kernel and the system
|
||||
services stay small (the kernel is measured in kilobytes, not megabytes), and
|
||||
nothing in this track changes that — Python never enters the OS layer. An
|
||||
**application** budget is different: a statically-linked CPython with its
|
||||
module set will be tens of megabytes in ReleaseSafe (the measured ~2×
|
||||
safety-check factor compounds it), and that is *allowed* — applications live
|
||||
on the FAT image, not in the kernel's world. It still shapes the image, and it
|
||||
means every Python service shares one interpreter binary + per-service
|
||||
scripts, so the spawn model needs **argv** before "run this .py" works at all.
|
||||
- **FAT mtime granularity is 2 seconds.** CPython's `.pyc` cache validation is
|
||||
mtime-based by default; a rapid edit-run cycle can see stale bytecode. Use
|
||||
hash-based `.pyc` invalidation (PEP 552, `--invalidation-mode checked-hash` at
|
||||
freeze time) or accept the quirk during bring-up.
|
||||
- **FAT name lookups are case-insensitive.** Long file names preserve case but
|
||||
match insensitively — the same world Python inhabits on Windows/macOS, so
|
||||
importlib copes, but two modules differing only by case cannot coexist on the
|
||||
image.
|
||||
- **`strtod`/float repr correctness.** Python's float round-tripping is exacting;
|
||||
lift musl's conversions rather than writing them, and run CPython's float tests
|
||||
early.
|
||||
- **Threadless build is load-bearing, initially.** Like WASI, the first builds have
|
||||
no working `threading`. The escape hatch is real (danos has native threads and
|
||||
futexes; a pthread subset is Phase-5 work) but keep the configuration honestly
|
||||
single-threaded until then.
|
||||
- **The test suite is the porting harness.** CPython ships its own conformance
|
||||
suite; getting `test_builtin`, `test_int`, `test_float`, `test_io` running on
|
||||
danos early converts "it seems to work" into a checklist. Budget image space for
|
||||
the test `Lib/` tree during bring-up.
|
||||
- **Entropy before exposure.** `PYTHONHASHSEED=0` is fine for bring-up and wrong
|
||||
forever; hash randomization exists because attacker-controlled dict keys are a
|
||||
denial-of-service vector. Land the entropy source before any Python service
|
||||
parses external input.
|
||||
|
||||
## Related
|
||||
|
||||
- [python-on-danos-milestones.md](python-on-danos-milestones.md) — the execution
|
||||
plan (P0–P5) for this note.
|
||||
- [c-library-compatibility.md](c-library-compatibility.md) — the mini-libc
|
||||
(libdanos-c) design behind Phase 0.
|
||||
- [character-devices-and-tty.md](character-devices-and-tty.md) — the stream-node /
|
||||
console / no-pty design behind Phase 1.
|
||||
- [zig-self-hosting.md](zig-self-hosting.md) — the sibling track; shares Phase 1,
|
||||
diverges at the libc.
|
||||
- [os-development/syscall.md](os-development/syscall.md) — the kernel ABI the
|
||||
mini-libc bottoms out in.
|
||||
- [os-development/vdso.md](os-development/vdso.md) — the public ABI boundary the
|
||||
`danos` extension module wraps.
|
||||
- [os-development/sysv.md](os-development/sysv.md) — the entry stack (argv/envp)
|
||||
the spawn-argv work extends.
|
||||
- [device-driver-development/ipc.md](device-driver-development/ipc.md) — the IPC
|
||||
surface Python services speak.
|
||||
- [file-system-development/file-system-hierarchy.md](file-system-development/file-system-hierarchy.md)
|
||||
— where `Lib/` and `site-packages` land on the image.
|
||||
@@ -0,0 +1,584 @@
|
||||
# Security track execution plan: paths, protocol namespace, SMEP/SMAP
|
||||
|
||||
The design is settled in
|
||||
[communication.md](os-development/communication.md),
|
||||
[protocol-namespace.md](os-development/protocol-namespace.md),
|
||||
[file-system-hierarchy.md](file-system-development/file-system-hierarchy.md),
|
||||
and [smep-smap.md](os-development/smep-smap.md). This file is the build order
|
||||
— one phase at a time, each phase green before the next starts. Delete or
|
||||
archive this file when the last milestone lands.
|
||||
|
||||
**Context a fresh session should read first:** the four design docs above,
|
||||
then this plan's *Settled decisions* section — those decisions came out of a
|
||||
full-code grounding pass (2026-07-31) and must not be re-derived or reopened.
|
||||
|
||||
**Definition of green, every phase:** `zig build` clean, `zig build test`
|
||||
clean, `python3 test/qemu_test.py` passes (existing scenarios plus the
|
||||
phase's new ones — record the suite count in the checkbox), and the relevant
|
||||
design doc's status/known-gap lines updated in the same commit. Commit per
|
||||
green phase, style `area: lower-case declarative summary`, **no co-author
|
||||
trailers**. On a suite failure, read
|
||||
`zig-out/qemu-test/<case>-failed-serial.log` before changing anything.
|
||||
|
||||
**Workflow:** work in a dedicated git worktree on feature branches cut from
|
||||
`main` (one branch per milestone group as marked below); when a group's
|
||||
phases are all green, merge to `main` and push. The loop marks a phase `[x]`
|
||||
in the same commit that lands it.
|
||||
|
||||
**Numbering note:** milestones use the design docs' own names (PM, H1–H3,
|
||||
HS, P1–P4) — the M-number sequence is left alone (M19–M22 are reserved by
|
||||
the logging/USB-lifecycle track).
|
||||
|
||||
## Status
|
||||
|
||||
**Live state — updated on `main` after every phase, so this file read from a
|
||||
plain `main` checkout always tells the truth about where the work is.**
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Working on | nothing — the track is complete |
|
||||
| Branch carrying it | — |
|
||||
| On `main` | every phase — groups 1, 2, 3 and 4 merged |
|
||||
| Awaiting merge | nothing |
|
||||
| Suite | 114 cases, all passing |
|
||||
| Last updated | 2026-08-01 |
|
||||
|
||||
A checkbox below means the phase met its definition of green and was
|
||||
committed — on the branch named above, which reaches `main` at the next
|
||||
group boundary.
|
||||
|
||||
- [x] **Phase 0** — baseline: suite green on `main` (106/106, 2026-07-31; `zig build` + `zig build test` clean at 9a32380), plan committed
|
||||
- [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)
|
||||
- [x] **P1** — envelope module + `Define`; vfs `NodeKind.protocol` + open-reply-capability; client `Channel` (mechanics only, nothing converted; suite unchanged at 107)
|
||||
- [x] **P2** — registry in init; `/protocol` reserved; ServiceId flag-day (11 binds, 17 lookups; `protocol.csv` grants, chain-attested identity, dead-owner rebind; the kernel's endpoint-death sweep generalized off the retired registry; suite 108/108). Three adversarial review rounds closed six defects a green suite had missed: a forged power event could shut the machine down; the ping path leaked a capability per call, first in init and then in the shared harness; supervisor attestation by name was defeated by a laundering deputy; and the kernel let any handle-holder bind signals, timers, exits and IRQs to an endpoint it did not own.
|
||||
- [x] **P3** — open grants: `protocol.csv` enforcement, denial test. `onOpen`
|
||||
consults the manifest with the same chain-attested identity a bind uses, and a
|
||||
refused caller gets the *same* answer as one naming a contract nobody bound —
|
||||
`-ENOENT`, no capability, the same reply bytes, no log line, and both questions
|
||||
asked on every open so there is nothing to time. Twenty-seven `open` rows cover
|
||||
the whole live client set. One wrinkle the plan had not foreseen: the driver
|
||||
tree is three deep (device manager → PS/2 bus → keyboard/mouse) and attestation
|
||||
is one hop, so a legitimate grandchild read exactly like a laundering deputy;
|
||||
the manifest gained a third permission, `supervise`, which names an authorized
|
||||
supervising task per contract and is deliberately **open-only**, leaving P2's
|
||||
bind attestation and every refusal it makes untouched (suite 109/109)
|
||||
- [x] **merge** group 2 → main, push
|
||||
- [x] **P4a** — clean protocols rebased onto `Define` (vfs, block, display, scanout, input; display's one overloaded request split per-operation and its field abuse ended, scanout's bogus 64-byte maximum deleted, directory EOF re-spelled as a nameless entry, input moved onto the service harness; new `protocol-conformance` case asks every reachable provider for `describe` and requires `-ENOSYS` for an undefined verb; suite 110/110)
|
||||
- [x] **P4b** — misfit protocols rebased (device-manager, power, usb-transfer; every leading operation byte folded into the header, and with it the `device_id`/`device_token` that followed it — `Header.target` now carries the device in all three. device-manager's own `enumerate`/`subscribe` became the reserved verbs and its three `{status, reserved}` reply structs the envelope's `Status`; `ChildAdded` is one struct under two numbers, a call and an event, landing exactly on the 64-byte push floor. power's kinds became one declared event each, the input protocol's shape, so init reads *what happened* from the header; usb-transfer's control data stage moved to the packet tail in both directions, which made `Status.len` the transferred length and `actual_length` redundant. The two silent-breakage sites — init's byte-offset power parse and acpi's `message[0]` dispatch — are gone, the shutdown badge gate unchanged; three more rows in the conformance table. Suite 110/110)
|
||||
- [x] **P4c** — harness subscriber lift + badge-scoped per-client integers (the
|
||||
subscriber table, the reserved subscribe/unsubscribe verbs, the fan-out and the
|
||||
dead-subscriber sweep are `service.Subscribers` now; input, acpi and
|
||||
device-manager deleted three hand-rolled variants and their three different
|
||||
ideas of when a subscriber goes away, standardizing on published exit
|
||||
notifications — acpi had no sweep at all and input polled the process list on
|
||||
every subscribe. The three guessable-id namespaces are scoped to the opening
|
||||
badge: FAT node ids on every verb that names one, xHCI device tokens on open,
|
||||
control, bulk and interrupt_subscribe, display layers on configure, fill, blit,
|
||||
damage and destroy — each refusing a wrong owner with the *same* answer as an id
|
||||
nobody holds. New `badge-scope` case, two processes of one fixture, every
|
||||
refusal paired with a control; suite 111/111)
|
||||
- [x] **merge** group 3 → main, push
|
||||
- [x] **H2** — SMEP on every core (shared CPUID helper; CR4 bit 20 set in the per-CPU bring-up both the BSP and every AP run, asserted per core by the smp case; ring-0-executes-user-pages audit clean incl. the pre-paging window on the loader's tables; fail-open with a posture line; `-cpu max` added to the harness since QEMU's default model has neither bit; new `fault-smep` case; suite 112/112)
|
||||
- [x] **HS** — SYSRET canonical-RIP guard (the syscall exit sign-extends the
|
||||
return RIP from bit 47 and returns through `iretq` when that changes it —
|
||||
four register ALU ops and a never-taken branch on the hot path, no load; the
|
||||
fallback un-pops the rip slot so `iretq` consumes the frame entry already
|
||||
built, reloads R11 from the rflags slot, and keeps the `swapgs` in the same
|
||||
place relative to the ring change. 4-level is not an assumption but a fact:
|
||||
nothing sets CR4.LA57, and the comment says what a 5-level port must change.
|
||||
Ring 3 **can** reach the hazard — `syscall` as the last two bytes of the last
|
||||
canonical page returns to `user_half_end` — so the new `sysret-canonical`
|
||||
case is a real ring-3 probe doing exactly that, and dies of a ring-3 #GP at
|
||||
`0x0000800000000000` while the kernel runs on. Its teeth are the refusal
|
||||
counter, not the outcome: measured with the guard's branch removed, TCG does
|
||||
not model Intel's ring-0 #GP and every outcome check still passed. Suite
|
||||
113/113)
|
||||
- [x] **H3** — SMAP on every core, and the boot-patched `clac` that makes it
|
||||
hold. Hardware does not clear `EFLAGS.AC` on interrupt delivery and ring 3
|
||||
sets it with `popfq`, so without the patch a hostile process suspends SMAP
|
||||
for the length of any handler it can provoke; `clac` is #UD without the
|
||||
feature, so the image ships the 3-byte canonical NOP at the first byte of
|
||||
`isr_common` — ahead of the CPL test, because a fault nested in ring 0
|
||||
inherits AC just as readily — and the boot processor overwrites it through
|
||||
the physmap, the same door `smp.arm` and `process.run` already use to write
|
||||
a page the executing mapping holds read-only. The two halves are wired
|
||||
together rather than merely ordered: `initHardening` refuses CR4 bit 21
|
||||
until the patch has been written *and* read back through the text address it
|
||||
will be fetched from, so no core can turn SMAP on ahead of it and a
|
||||
translation that lied leaves the machine unhardened and saying so instead of
|
||||
enforcing over an entry path that cannot clear AC. The `smp` case now
|
||||
requires SMAP on every core it lands on, which is the ordering checked from
|
||||
the far end. New `fault-smap` case reads a mapped user page from ring 0 and
|
||||
requires the fault: error code `0x1` — present, and nothing else, since a
|
||||
SMAP violation has no bit of its own and U/S reports the ring of the access.
|
||||
The suite is now the standing enforcement test, and it found nothing left to
|
||||
find: H1's conversion of the nine stragglers was complete. Suite 114/114
|
||||
- [x] **merge** group 4 → main, push
|
||||
|
||||
---
|
||||
|
||||
## Settled decisions (grounding pass, 2026-07-31 — do not reopen)
|
||||
|
||||
These resolve every open wrinkle the code inventory surfaced. Where one
|
||||
amends a design doc, the amendment lands in the same commit as the phase
|
||||
that implements it.
|
||||
|
||||
1. **Every packet — request, reply, and event — begins with the envelope
|
||||
`Header`, exactly as the design says; the header is FOLDED, never
|
||||
stacked.** It absorbs each protocol's existing operation/id fields
|
||||
rather than sitting on top of them, so the two apparent 64-byte-limit
|
||||
offenders fit: `ChildAdded` re-lays to 60 bytes (its packed operation
|
||||
byte and `device_id` become `Header.operation`/`.target`);
|
||||
`InterruptReport` puts `device_token` in `Header.target` and trims
|
||||
inline data 48 → 40 bytes (largest real report today is 8). A
|
||||
headerless-events variant was considered and REJECTED (2026-07-31): it
|
||||
re-invents per-protocol mini-headers and breaks uniform tooling. No
|
||||
design-doc amendment; `Define`'s event check stays ≤ 64 *including*
|
||||
the header.
|
||||
2. **Bind/open authorization is chain-attested identity: the
|
||||
kernel-stamped binary name PLUS the supervision chain**, both read from
|
||||
the kernel's process records (`ProcessDescriptor` carries `name` and
|
||||
`supervisor`; init walks the chain with `process_enumerate` — no new
|
||||
protocol). A grant row names the binary *and* the supervisor expected
|
||||
in its chain, so a malicious process re-spawning a granted binary
|
||||
(ungated `spawn`, hostile argv — the confused deputy) is refused: its
|
||||
chain roots at the attacker, not at init or device-manager. Name alone
|
||||
is NOT sufficient — that was considered and rejected (2026-07-31).
|
||||
Pure delegation (device-manager forwarding driver binds as
|
||||
capabilities — "option B") is deliberately deferred to P5, whose
|
||||
spawner-wired namespaces subsume it. Amends protocol-namespace.md's
|
||||
"Authorization" bullet in P2.
|
||||
3. **Grants live in a new manifest, `/system/configuration/protocol.csv`**
|
||||
(rows: `binary-path, supervisor, bind|open, protocol-name`, where
|
||||
`supervisor` is the binary expected in the caller's supervision chain —
|
||||
`init` for init's own children, `kernel` for harness-spawned fixtures),
|
||||
not in extra init.csv columns — today every post-path init.csv field is
|
||||
argv, and overloading that is ambiguous. init parses both files.
|
||||
*(P2 spelling: the supervisor column carries the binary exactly as the
|
||||
kernel stamped it, so init's own children say `/system/services/init` and
|
||||
the drivers say `/system/services/device-manager`; `kernel` stays a bare
|
||||
word because a kernel task has no binary. A trailing `*` on any field
|
||||
matches a subtree, which is how decision 4's `/test/` rule is expressed.)*
|
||||
*(Clarification, 2026-08-01: the supervisor column names **the authorized
|
||||
supervising task, matched by identity** — the binary is how the row spells
|
||||
it, but init checks the task id. `kernel` is satisfied only by supervisor
|
||||
id 0 (which only the kernel confers — user `system_spawn` always stamps the
|
||||
caller); init's own path only by this init's task id; any other path only by
|
||||
a task init spawned itself or one the kernel spawned. Matching the supervisor
|
||||
by *name* alone is defeated by a laundering deputy — an attacker runs its own
|
||||
instance of `/system/services/init`, has that spawn `/system/services/input`,
|
||||
and both stamped names satisfy the row while the chain is entirely the
|
||||
attacker's. Walking to the root of the chain does not fix it either, since
|
||||
the laundered chain still roots at the real PID 1.)*
|
||||
*(P3 amendment: a third permission, `supervise`, joins `bind|open`. One-hop
|
||||
attestation cannot express the one three-deep chain in the tree — the device
|
||||
manager starts the PS/2 bus, and the bus starts the keyboard and mouse
|
||||
drivers — and nothing structural tells that chain apart from the laundering
|
||||
deputy, since both are a granted binary spawned by a granted binary. Only
|
||||
policy can: a `supervise` row names the authorized supervising task the way
|
||||
every other row names a claimant (binary, its own supervisor, the contract it
|
||||
concerns), and an `open` row may then name that task in its supervisor
|
||||
column. The delegate is itself attested the ordinary strict way, so the chain
|
||||
still anchors in init or the kernel one hop above it and the recursion stops
|
||||
there. It is **open-only** on purpose — a delegate may vouch for what its
|
||||
children *reach*, never for what they *claim* — so the bind path is
|
||||
byte-for-byte P2's and the laundering-deputy refusal is untouched.)*
|
||||
4. **Test fixtures bind under `/protocol/test/...`**, granted to any
|
||||
binary whose path starts `/test/` — the subtree-scoping rule from the
|
||||
design doc, dogfooded. `shared_memory_test` (the borrowed-ServiceId
|
||||
hack) becomes `/protocol/test/shared-memory`; process-test's child gets
|
||||
`/protocol/test/process`.
|
||||
5. **Rebind after provider death:** a `bind` hitting an existing binding
|
||||
succeeds only if the current owner process is dead (init checks
|
||||
liveness); otherwise `-EBUSY`. Init also unbinds in `restartChild`
|
||||
before respawning its own children. This preserves collision-refusal
|
||||
while making restart work for providers init does not supervise.
|
||||
6. **Cross-thread service access** (the display mouse-listener's
|
||||
per-thread self-lookup, `display.zig:512`): threads resolve and open
|
||||
`/protocol/<name>` like any client — once, at thread startup. No
|
||||
special mechanism.
|
||||
7. **The envelope module is `library/protocol/envelope/envelope.zig`**
|
||||
(module name `envelope`) — the one protocol-package module not ending
|
||||
in `-protocol`, because it is not a protocol. Wired as a new
|
||||
`addModule` row in `library/protocol/build.zig` with its host tests in
|
||||
that package's test step.
|
||||
8. **The QEMU harness gains `-cpu max`** (in `qemu_args`,
|
||||
`test/qemu_test.py:66-83`) so TCG exposes SMEP/SMAP — without it the
|
||||
enabled paths never execute in CI. Landed in H2 so the flag soaks
|
||||
before H3 depends on it.
|
||||
9. **Scenario fixtures that need the registry are init-driven.** Kernel
|
||||
test cases that today spawn providers directly (shared-memory,
|
||||
process-test) either spawn init first or move to init.csv-driven
|
||||
scenario boots — resolved per-case in P2 with the suite as the
|
||||
arbiter.
|
||||
*(P2 resolution: init gained a `registry` argv role — it mounts
|
||||
`/protocol`, reads the grants, and starts no services — and each affected
|
||||
case calls `spawnRegistry(rd)` before its own providers. Every case keeps
|
||||
its own spawn set, so no scenario had to be re-shaped.)*
|
||||
10. **The capsule-staleness caveat is documented, not fixed.** On-volume
|
||||
edits to `/system/configuration/*.csv` do not reach the initrd copy
|
||||
the loader boots (capsule shadows tree). Same drift exists today with
|
||||
`/etc`; PM adds the note to file-system-hierarchy.md and moves on.
|
||||
|
||||
---
|
||||
|
||||
## PM — path-migration flag-day
|
||||
|
||||
One commit, everything moves together. The authoritative site inventory is
|
||||
the grounding pass; the checklist order:
|
||||
|
||||
1. Move repo `etc/` → `configuration/` sources; fix the three CSVs'
|
||||
self-referencing headers (`etc/init.csv:1,12`, `etc/devices.csv:1`,
|
||||
`etc/init-diagnose.csv:1`).
|
||||
2. `build.zig:309-311`: bundled entries `etc/...` →
|
||||
`system/configuration/...` (this alone re-shapes the image, manifest,
|
||||
and capsule — `tools/make-fat-image.py` and the EFI loader need
|
||||
nothing; the tree-walk fallback even starts picking the CSVs up, a
|
||||
bonus fix).
|
||||
3. `system/kernel/vfs.zig` `mountBackend` (`:332-340`): allow exactly
|
||||
`/system/configuration` and `/system/logs` as backend prefixes beneath
|
||||
the initrd `/system` mount; keep refusing everything else under
|
||||
`/system` and `/test`.
|
||||
4. `system/services/fat/fat.zig`: `mount_point` → `/volumes/usb` (`:25`);
|
||||
replace the `/var` mount (`:155`) with two `mountRewritten` calls for
|
||||
`/system/configuration` and `/system/logs`; update the mount log lines
|
||||
(the harness matches them).
|
||||
5. `system/services/init/init.zig:76` and
|
||||
`system/services/device-manager/device-manager.zig:48`: open the new
|
||||
CSV paths; update the message strings (`init.zig:77,92`,
|
||||
`device-manager.zig:49,61-63,454`).
|
||||
6. `system/services/logger/logger.zig:44`: `base = "/system/logs"`
|
||||
(buffers derive from `base.len` comptime — nothing else changes).
|
||||
7. `system/kernel/tests.zig:2808-2810`: exclude `/system/configuration/`
|
||||
from the spawn-everything sweep (the CSVs are not programs).
|
||||
8. Tests: `fat-test.zig` and `vfs-test.zig` `/mnt/usb` literals →
|
||||
`/volumes/usb`; harness regexes `test/qemu_test.py:175,211,632,717`.
|
||||
9. Comment sweep (init, device-manager, logger, fat, engine, vfs, abi,
|
||||
file-system, csv, device, protocol/device-manager, drivers, acpi,
|
||||
build.zig — full list in the grounding inventory); delete vestigial
|
||||
repo `var/`.
|
||||
|
||||
**Test:** no new case — the existing 106 are the test, since fat/logger/
|
||||
init/device-manager scenarios all assert the new paths through their
|
||||
regexes. Suite stays 106.
|
||||
|
||||
## H1 — user-memory copy discipline
|
||||
|
||||
New kernel module `system/kernel/user-memory.zig`:
|
||||
|
||||
- `copyFromUser` moves from ipc-synchronous.zig (which re-exports or
|
||||
imports it); new `copyToUser(user_as, user_va, source) bool` — the
|
||||
mechanical mirror (kernel-source `copyAcross` already does this for IPC
|
||||
replies at `ipc-synchronous.zig:431,460`).
|
||||
- The page walk gains leaf U/S and writable checks: `paging.translateIn`
|
||||
(`architecture/x86_64/paging.zig:513-525`) tests only `present` today —
|
||||
add a flags-accumulating variant (2 MiB leaves included); reads require
|
||||
U/S, writes require U/S+W. Closes the TODO at
|
||||
`ipc-synchronous.zig:20-22`.
|
||||
- Convert the nine stragglers (table in smep-smap.md). Read direction is
|
||||
local to `process.zig`; the write direction restructures callees with
|
||||
kernel bounce buffers: `scheduler.enumerate` (`scheduler.zig:1209`),
|
||||
`devices_broker.enumerate` (`devices-broker.zig:136`), `log.readAt`
|
||||
(`log.zig:209`), and the `fs_node` flows through
|
||||
`vfs.nodeRead/nodeStatus/nodeReaddir` (`vfs.zig:257/269/289`).
|
||||
|
||||
**Test:** kernel unit coverage in `system/kernel/tests.zig` for
|
||||
`copyToUser` bounds/permission refusals; one new QEMU case `user-memory` —
|
||||
a fixture passes an unmapped-but-in-range buffer to `klog_read`,
|
||||
`process_enumerate`, and `fs_resolve` and asserts `-EFAULT` returns with
|
||||
the system still alive (today each would oops the kernel). Suite 107.
|
||||
|
||||
## P1 — envelope, vfs additions, Channel
|
||||
|
||||
- `library/protocol/envelope/envelope.zig`: `Header` {operation:u32, pad,
|
||||
target:u64}, `Status`, reserved verbs (describe=0, enumerate=1,
|
||||
subscribe=2, unsubscribe=3, protocol verbs from 16), `packet_maximum`
|
||||
= 256 / `post_maximum` = 64 (the floor constants protocols compile
|
||||
against — nothing exports them today), and comptime
|
||||
`Define(.{name, version, operations, events})` generating request/reply
|
||||
types, encode/decode, a provider dispatch table (automatic `describe`,
|
||||
`-ENOSYS` for unknown verbs), and compile-time size checks:
|
||||
request/reply ≤ 256, each `.events` entry ≤ 64 *including* its Header
|
||||
(decision 1). Host unit tests in the protocol package's test step.
|
||||
- `library/protocol/vfs/vfs-protocol.zig`: `NodeKind.protocol = 7`; the
|
||||
open-reply-may-carry-capability convention documented in the module.
|
||||
Rewrite the value-pinning unit test (`:108-117`) to pin the *new*
|
||||
stable values.
|
||||
- `library/kernel/file-system.zig` + a new `Channel` type in
|
||||
`library/kernel` (or `library/client`): `open("/protocol/<name>")` →
|
||||
resolve, vfs open, receive the reply capability → a `Channel` wrapping
|
||||
the handle with `call`/typed helpers. Nothing uses it yet — P2 converts
|
||||
the world.
|
||||
- Docs: vfs-protocol.md's NodeKind table gains value 7 (no
|
||||
protocol-namespace.md amendment — decision 1 conforms to it as written).
|
||||
|
||||
**Test:** host unit tests only (envelope round-trips, size-check compile
|
||||
errors via `error` tests, Channel plumbing against a mock). Suite stays
|
||||
107.
|
||||
|
||||
## P2 — the registry; ServiceId flag-day
|
||||
|
||||
The single biggest phase; one branch, may be several commits, green at the
|
||||
end of each.
|
||||
|
||||
- **init as registry backend** (`system/services/init/init.zig`): a second
|
||||
endpoint (the supervision endpoint's reply-empty loop is unsuitable for
|
||||
a vfs backend); serve vfs `open`/`readdir` over `/protocol` plus the
|
||||
`bind` operation (name payload + capability). Mount `/protocol` before
|
||||
spawning children. Parse `/system/configuration/protocol.csv`
|
||||
(decision 3). Authorization by chain-attested identity (decision 2):
|
||||
badge → kernel process records → binary name **and** supervision chain
|
||||
(walk `supervisor` links) checked against the grant row's expected
|
||||
supervisor. Unbind on child death in `restartChild`; dead-owner rebind
|
||||
rule (decision 5).
|
||||
Provenance: readdir/diagnostics show name → pid → binary path.
|
||||
- **Kernel:** reserve `/protocol` — `mountBackend` refuses mounts at or
|
||||
under it once bound, `installMount`'s remount-replace path refuses it,
|
||||
and `fs_unmount` refuses it (`vfs.zig:164-181,332-351`,
|
||||
`process.zig:1879-1889`). First mount wins (init is PID 1).
|
||||
- **Harness:** `library/kernel/service.zig` `Callbacks.service:
|
||||
?abi.ServiceId` becomes a protocol name; the register call (`:49-51`)
|
||||
becomes bind-with-retry via the registry.
|
||||
- **Flag-day conversion** — all 11 registration sites and 17 lookup sites
|
||||
from the grounding inventory: providers (input:123, ps2-bus:223,
|
||||
device-manager:569, acpi:193, usb-xhci-bus:676, usb-storage:205,
|
||||
fat:307, display:699, virtio-gpu:550, shared-memory-server:43,
|
||||
process-test:130 → `/protocol/test/...` per decision 4); clients
|
||||
(input-client:53, display-client:28, driver.zig:173, usb.zig:139,
|
||||
block.zig:72+87, ps2-bus keyboard:35 + mouse:34, virtio-gpu:478,
|
||||
display:314+512 (decision 6), acpi:212, init:218+245 — init
|
||||
short-circuits its own registry, shared-memory-client:22,
|
||||
process-test:85, device-list:22, crash-test:32). Retry loops keep their
|
||||
cadence, wrapping resolve+open instead of lookup.
|
||||
- **Delete:** `abi.zig:36-37` (syscall ids — leave holes),
|
||||
`abi.zig:287-303` (enum), `process.zig:223-224,314-343`,
|
||||
`ipc-synchronous.zig:41-43,646-664` and the registry sweep in
|
||||
`:121-140`; the wrappers `library/kernel/ipc.zig:33-35,47-50`; comment
|
||||
sweep (irq.zig:50, tests.zig:3744, vdso.md's syscall table, the docs
|
||||
list in the inventory).
|
||||
- Kernel-spawned test scenarios made init-driven where they need the
|
||||
registry (decision 9).
|
||||
|
||||
**Test:** new QEMU case `protocol-registry`: a fixture asserts (a) bind of
|
||||
an ungranted name → `-EPERM`, (b) bind collision with a live owner →
|
||||
`-EBUSY`, (c) provider kill → re-resolve reaches the restarted instance.
|
||||
Every existing scenario doubles as conversion proof. Suite 108.
|
||||
|
||||
## P3 — open grants (restriction stage one)
|
||||
|
||||
- `protocol.csv` `open` rows enforced in the registry's `open` handler,
|
||||
same name-based identity as bind. Default rows grant what today's
|
||||
clients need (from the P2 conversion table); a deliberate hole for the
|
||||
test fixture.
|
||||
- Docs: protocol-namespace.md stage-one section gets its "landed" line.
|
||||
|
||||
**Test:** new QEMU case `protocol-denied`: a fixture granted
|
||||
`/protocol/test/shared-memory` but not `/protocol/display` asserts open of
|
||||
the first succeeds and the second fails identically to not-found. Suite
|
||||
109.
|
||||
|
||||
*Landed. Four things the plan did not foresee, recorded because P4 and P5
|
||||
inherit them:*
|
||||
|
||||
- *`supervise` — decision 3's amendment. The PS/2 keyboard and mouse drivers
|
||||
are started by the PS/2 bus driver, which the device manager started: the
|
||||
tree's one three-deep chain, and one hop deeper than attestation reaches.
|
||||
Nothing structural separates it from the laundering deputy, so the manifest
|
||||
says which delegate is authorized, per contract. Open-only, so P2's bind
|
||||
attestation is unchanged.*
|
||||
- *Indistinguishability is a claim about work, not only about bytes. `onOpen`
|
||||
refreshes the process table, identifies the caller, scans the grants and
|
||||
scans the bindings on **every** open and forms one verdict at the end; and
|
||||
it logs nothing on any branch, because `klog_read` is ungated (a line
|
||||
written on one branch is a line the refused caller can read) and a serial
|
||||
line is milliseconds it could time. The operator's diagnosis is the pair the
|
||||
namespace publishes anyway: `readdir /protocol` for what is bound, the
|
||||
manifest for who may reach it.*
|
||||
- *The fixture is `protocol-denied-test`, and its scenario boots the **input
|
||||
service** so the forbidden name is genuinely bound — the fixture reads the
|
||||
namespace listing to prove it before asking for it. Without a live provider
|
||||
the case would be comparing two boot races and asserting nothing.*
|
||||
- *Two channels stay open by design, named rather than papered over: `readdir`
|
||||
over `/protocol` lists every bound name to anyone (deliberate — the tree is
|
||||
diagnosable), and `/system/configuration/protocol.csv` is world-readable on
|
||||
the `/system` mount. Stage one hides neither the set of contracts nor the
|
||||
policy; what it removes is the **oracle in the reply**, which is what stage
|
||||
two's parked and faked opens depend on.*
|
||||
|
||||
## P4a — clean protocols onto Define
|
||||
|
||||
vfs, block, display, scanout, input — the modules whose shapes map
|
||||
directly (grounding inventory §1,3,4,6,8):
|
||||
|
||||
- vfs: `node` → `target`; `Reply.node` (open's result) moves to reply
|
||||
payload — `library/kernel/file-system.zig` decoders change; readdir
|
||||
stays a protocol verb.
|
||||
- block: pure renumber; `attach`'s DMA cap rides the call as today.
|
||||
- display: the overloaded 40-byte `Request` becomes per-operation structs
|
||||
(attach_scanout's field abuse dies); `layer` → `target`; blit payload
|
||||
grows to 224 bytes.
|
||||
- scanout: renumber; drop its bogus `message_maximum=64` (sync floor is
|
||||
256); fix virtio-gpu's hard-coded `service.run(256, …)` to the
|
||||
generated constant.
|
||||
- input: subscribe merges into reserved subscribe; publish renumbers;
|
||||
the event re-lays onto the Header folded (operation = event kind,
|
||||
target = 0; 16 + 28-byte payload = 44 ≤ 64); **input moves onto the
|
||||
service harness** (it is the last hand-rolled loop, no ping/terminate
|
||||
compliance today).
|
||||
|
||||
**Test:** new QEMU case `protocol-conformance`: a fixture opens every
|
||||
registered protocol and asserts `describe` answers (name, version) and an
|
||||
unknown verb returns `-ENOSYS`. Existing input/display/fat scenarios prove
|
||||
the rebase. Suite 110.
|
||||
|
||||
## P4b — misfit protocols onto Define
|
||||
|
||||
device-manager, power, usb-transfer (inventory §2,5,7 — the u8-operation
|
||||
re-layouts and raw-offset readers):
|
||||
|
||||
- device-manager: u8 operations → Header; its enumerate=4/subscribe=5
|
||||
merge into the reserved verbs; `ChildAdded` splits its dual role —
|
||||
request struct and event, both Header-first (folded to 60 B ≤ 64);
|
||||
`ChildRemoved`'s (parent, bus_address) addressing stays payload.
|
||||
- power: u8 operations → Header; subscribe merges; **init's raw
|
||||
byte-offset event parsing (`init.zig:171-173`) and acpi's
|
||||
`message[0]` dispatch (`acpi.zig:435-467`) are rewritten against the
|
||||
generated types** — the two silent-breakage sites, called out so the
|
||||
loop treats them as first-class conversions, not collateral.
|
||||
- usb-transfer: `device_token` → `target` (already layout-identical);
|
||||
`InterruptReport` re-lays onto the Header (`device_token` → `target`,
|
||||
inline data trimmed 48 → 40 — largest real report is 8); control/bulk
|
||||
budgets re-verified by `Define` (Status absorbs `actual_length`).
|
||||
|
||||
**Test:** existing scenarios are the proof (device hot-add, power button,
|
||||
USB storage/HID all exercise these wires); the conformance case now covers
|
||||
three more providers. Suite 110.
|
||||
|
||||
*Landed. Three judgment calls the plan left open, recorded because a reader of
|
||||
the wire formats will want them:*
|
||||
|
||||
- *`ChildAdded` is 48 bytes, not the 44 the "60 B" estimate assumed: three `u64`s
|
||||
give the struct eight-byte alignment, so 41 bytes of content round up whatever
|
||||
order the fields sit in. The packet is therefore **exactly** 64 — on the push
|
||||
floor, not under it — which `Define` accepts and the module pins in a test. The
|
||||
fields are ordered small-tail-last deliberately, so the slack the rounding pays
|
||||
for is where the small ones live.*
|
||||
- *power's events are declared **per kind** (`power_button`, `lid`, `ac`,
|
||||
`battery`, `notify`), not one `event` with the kind in the payload. That is the
|
||||
shape P4a gave input — "the class is the header's operation, so a subscriber
|
||||
reads the kind from the packet rather than from a tag inside the payload" — and
|
||||
it is what makes `Header.operation` carry information here at all. It also kept
|
||||
every call site's spelling: `Protocol.Event` is re-exported as the protocol's
|
||||
own `Event`, with the members it always had.*
|
||||
- *the conformance case still checks two providers, and the three new rows report
|
||||
as unbound. All three P4b contracts arrive with the device manager — it is the
|
||||
first, it spawns the discovery service that binds the second, and the xHCI
|
||||
driver that binds the third — so booting one means booting the driver tree, and
|
||||
the fixture takes **one snapshot** of `/protocol`: a scenario whose bound set
|
||||
depends on how far that tree got would make the case's own summary line a boot
|
||||
race. The rows still earn their place — a future scenario that binds one gets it
|
||||
checked with no edit here, and `/test/*` already holds the `open` grant for
|
||||
`device-manager`.*
|
||||
|
||||
## P4c — harness subscriber lift + badge scoping
|
||||
|
||||
- `library/kernel/service.zig` grows the subscriber table, exit-
|
||||
notification sweep, and fan-out loop declared via `Define(.events)`;
|
||||
input (:33-116), acpi (:67-68,393-406), and device-manager (:155-166)
|
||||
delete their hand-rolled variants. One sweep idiom: exit notifications
|
||||
(fat's pattern), replacing input's process-list polling and acpi's
|
||||
none-at-all.
|
||||
- Badge-scoped per-client integers (the guessable-id holes): fat node ids
|
||||
gain owner checks on every operation (`fat.zig:72-76`), xhci device
|
||||
tokens validate sender and sweep on exit (`usb-xhci-bus.zig:66-88,479`),
|
||||
display layers gain an owner field.
|
||||
|
||||
**Test:** extend the fat scenario: a second fixture guesses the first's
|
||||
node id and asserts refusal; kernel-side unit test for the harness sweep.
|
||||
Suite 111.
|
||||
|
||||
*Landed. Four things the plan had not foreseen:*
|
||||
|
||||
- *One sweep idiom means one more kernel subscriber per provider, and the kernel's
|
||||
published-exit table held **eight**. A normal boot now fields six (fat, input,
|
||||
power, device-manager, display, and one per xHCI controller), so the table grew
|
||||
to sixteen. It is not a table anyone notices until a service silently loses its
|
||||
sweep, which is exactly the failure the old ceiling was two subscriptions away
|
||||
from.*
|
||||
- *The device manager hears each of its drivers die **twice** now — it is both the
|
||||
supervisor its spawn named and, through the harness, a subscriber to published
|
||||
exits — and the notify ring delivers the two badges separately. Untreated, one
|
||||
death counted as two: the restart backoff doubled and the crash-loop cap fired
|
||||
at half the deaths it names. `onDriverExit` therefore retires the dead process
|
||||
id before it decides anything, and the second notification finds nothing to act
|
||||
on. (The `driver-restart` and `pci-scan` drills are what would have caught it.)*
|
||||
- *Refusal-equals-absence has a corollary for the verbs that **release**: FAT's
|
||||
`close` used to answer 0 for an unknown node, so scoping it had to change that
|
||||
too — a foreign node and a free one both answer `-ENOENT`, or the pair would
|
||||
have been an oracle for which ids are live. The same applies to the harness's
|
||||
`unsubscribe`.*
|
||||
- *Ownership is per **task**, not per process, because the badge is: the kernel
|
||||
stamps the sending thread's id, which is already the granularity of the exit
|
||||
sweep that releases the state (a worker thread's death releases the handles that
|
||||
worker opened). Nothing in the tree shares an id across its own threads today;
|
||||
a per-process notion would need the kernel to stamp the leader, and belongs with
|
||||
P5's spawner-wired namespaces if it is ever wanted.*
|
||||
|
||||
## H2 — SMEP
|
||||
|
||||
- Generalize the cpuid helper (`apic.zig:351-365`, private, subleaf-0) to
|
||||
a shared probe; gate on `cpuid(0).eax >= 7`.
|
||||
- Set CR4 bit 20 in `per-cpu.zig:initSystemCall` (or a sibling called
|
||||
from both `cpu.zig:148` and `smp.zig:181` — the one path both BSP and
|
||||
every AP already execute). Log enabled/absent (fail-open, IOMMU style).
|
||||
- Harness: add `-cpu max` to `qemu_args` (decision 8).
|
||||
|
||||
**Test:** new QEMU case `fault-smep` mirroring the `fault-*` injector
|
||||
pattern (`tests.zig:3906-3938`): ring-0 call through a pointer into a
|
||||
user-mapped page; expect `page fault (vector 14)` + `error code : 0x11` +
|
||||
kernel-half IP, machine reports the exception (deliberate-exception cases
|
||||
put the text in `expect`, per `qemu_test.py:189`). Suite 112.
|
||||
|
||||
## HS — SYSRET canonical-RIP guard
|
||||
|
||||
- `isr.s` syscall exit (`:256`): validate RCX canonicality before
|
||||
`sysretq`; non-canonical → `iretq` fallback (or kill), per the hazard
|
||||
note at `isr.s:192-194`.
|
||||
|
||||
**Test:** kernel unit case driving a thread whose return RIP is forged
|
||||
non-canonical via the syscall path if constructible cheaply; otherwise the
|
||||
review-level proof plus the existing fault cases regression. Suite 112.
|
||||
*(Landed: it was constructible, and from ring 3 rather than by forgery — the
|
||||
`sysret-canonical` case, suite 113.)*
|
||||
|
||||
## H3 — SMAP
|
||||
|
||||
- `clac` patch site at `isr_common` (`isr.s:367`, before the CPL test —
|
||||
ring-0 nesting inherits AC too): assemble a 3-byte NOP, patch to `clac`
|
||||
at boot through the physmap (the `process.zig:1990-1995` /
|
||||
`smp.zig:79-111` precedent), BSP-only before AP bring-up.
|
||||
- Set CR4 bit 21 in the same per-CPU init as SMEP.
|
||||
- Coding standards: kernel code touches user memory only through
|
||||
`user-memory`; no `stac` anywhere, ever.
|
||||
|
||||
**Test:** new QEMU case `fault-smap`: ring-0 deliberate read of a mapped
|
||||
user page; expect vector 14 + `error code : 0x1` + kernel IP. And the
|
||||
whole suite becomes the tripwire — any missed straggler now fails loudly.
|
||||
*(Landed: the patch site sits at the very first byte of `isr_common`, and
|
||||
CR4.SMAP is refused on every core until the patch has been read back through
|
||||
the text mapping it will execute from — so the ordering is enforced, not
|
||||
merely documented. Error code observed: `0x1` exactly, present and nothing
|
||||
else. The tripwire found no missed straggler: H1 had converted them all.)*
|
||||
Suite 114 (HS added one).
|
||||
|
||||
---
|
||||
|
||||
**Explicitly out of scope** (own tracks, after this plan): P5 restriction
|
||||
stage two (spawn's initial capability, namespace views, parked replies,
|
||||
dedicated killable channels — needs a design session on the spawn
|
||||
contract), file-path namespacing, trusted UI (display track), pipes/FIFOs
|
||||
(Python track), `/applications` and its storage, `fs_mount`/`spawn`/
|
||||
`klog_read` gating beyond the `/protocol` reserved prefix, KPTI, IPC
|
||||
priority inheritance.
|
||||
@@ -347,7 +347,7 @@ Two current decisions fall out of this roadmap:
|
||||
- [syscall.md](os-development/syscall.md) — the kernel↔runtime ABI `runtime.os` is built on.
|
||||
- [sysv.md](os-development/sysv.md) — the entry stack (`argc/argv/envp/auxv`) danos already constructs.
|
||||
- [ipc.md](device-driver-development/ipc.md) — the IPC the VFS/FAT operations travel over.
|
||||
- [danos-file-system-hierarchy-FSH.md](file-system-development/danos-file-system-hierarchy-FSH.md) — the
|
||||
- [file-system-hierarchy.md](file-system-development/file-system-hierarchy.md) — the
|
||||
filesystem layout the file surface serves.
|
||||
- [coding-standards.md](coding-standards.md) — danos naming (why the compat spellings
|
||||
are confined, and now retired).
|
||||
|
||||
@@ -12,10 +12,20 @@ pub fn build(b: *std.Build) void {
|
||||
|
||||
const ipc = kernel.module("ipc");
|
||||
const time = kernel.module("time");
|
||||
// Every client reaches its service by name now: resolve `/protocol/<name>`,
|
||||
// open it, and take the provider's endpoint out of the reply
|
||||
// (docs/os-development/protocol-namespace.md).
|
||||
const channel = kernel.module("channel");
|
||||
|
||||
// A client frames its own packets, so it needs the envelope alongside the
|
||||
// protocol whose verbs it speaks.
|
||||
const envelope = protocol.module("envelope");
|
||||
|
||||
_ = b.addModule("display-client", .{
|
||||
.root_source_file = b.path("display/display-client.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "envelope", .module = envelope },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "display-protocol", .module = protocol.module("display-protocol") },
|
||||
@@ -24,6 +34,8 @@ pub fn build(b: *std.Build) void {
|
||||
_ = b.addModule("input-client", .{
|
||||
.root_source_file = b.path("input/input-client.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "envelope", .module = envelope },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "input-protocol", .module = protocol.module("input-protocol") },
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
//! User-space display client: talk to the display service (query the mode, and — from D3
|
||||
//! — create layers, draw, and present) without hand-rolling the IPC. The `runtime.block`
|
||||
//! shape: a cached `.display` lookup with a boot-race retry, then extern-struct request/
|
||||
//! shape: a cached `/protocol/display` open with a boot-race retry, then extern-struct request/
|
||||
//! reply marshalling. See system/services/display/ and docs/display.md.
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const display_protocol = @import("display-protocol");
|
||||
|
||||
const Protocol = display_protocol.Protocol;
|
||||
|
||||
/// The display's current mode, as `info()` reports it.
|
||||
pub const Info = struct {
|
||||
width: u32,
|
||||
@@ -19,13 +23,13 @@ pub const Info = struct {
|
||||
/// The service endpoint, looked up once and cached.
|
||||
var handle: ?ipc.Handle = null;
|
||||
|
||||
/// Look up the display service, retrying while it comes up (a client races its
|
||||
/// registration at boot). Returns the endpoint, or null if it never appears.
|
||||
/// Open `/protocol/display`, retrying while it comes up (a client races the
|
||||
/// service's bind at boot). Returns the endpoint, or null if it never appears.
|
||||
fn service() ?ipc.Handle {
|
||||
if (handle) |h| return h;
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.display)) |h| {
|
||||
if (channel.openEndpoint("display")) |h| {
|
||||
handle = h;
|
||||
return h;
|
||||
}
|
||||
@@ -34,29 +38,45 @@ fn service() ?ipc.Handle {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Send one request, receive its reply; true on a zero status. `out` receives the reply
|
||||
/// so callers can read `info`/`layer` fields on success.
|
||||
fn transact(request: display_protocol.Request, out: *display_protocol.Reply) bool {
|
||||
const h = service() orelse return false;
|
||||
var req = request;
|
||||
var reply: [display_protocol.reply_size]u8 = undefined;
|
||||
const len = ipc.call(h, std.mem.asBytes(&req), &reply) catch return false;
|
||||
if (len < display_protocol.reply_size) return false;
|
||||
out.* = std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]);
|
||||
return out.status == 0;
|
||||
/// A reply the compositor answered with, kept whole so the caller can decode the
|
||||
/// verb's own fixed part out of it.
|
||||
const Answered = struct {
|
||||
packet: [display_protocol.message_maximum]u8,
|
||||
len: usize,
|
||||
|
||||
fn bytes(self: *const Answered) []const u8 {
|
||||
return self.packet[0..self.len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Send one request (`target` addresses a layer, or 0 for the compositor itself)
|
||||
/// and keep the reply. Null when the transport failed or the compositor refused.
|
||||
fn transact(
|
||||
comptime operation: Protocol.Operation,
|
||||
target: u64,
|
||||
request: Protocol.RequestOf(operation),
|
||||
tail: []const u8,
|
||||
) ?Answered {
|
||||
const h = service() orelse return null;
|
||||
var packet: [display_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(operation, target, request, tail, &packet) orelse return null;
|
||||
var answered: Answered = .{ .packet = undefined, .len = 0 };
|
||||
answered.len = ipc.call(h, framed, &answered.packet) catch return null;
|
||||
const status = envelope.statusOf(answered.bytes()) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
return answered;
|
||||
}
|
||||
|
||||
/// The display's current mode, or null if the service never came up.
|
||||
pub fn info() ?Info {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
if (!transact(.{ .operation = @intFromEnum(display_protocol.Operation.info) }, &reply)) return null;
|
||||
const answered = transact(.info, 0, {}, &.{}) orelse return null;
|
||||
const reply = Protocol.decodeReply(.info, answered.bytes()) orelse return null;
|
||||
return .{ .width = reply.width, .height = reply.height, .pitch = reply.pitch, .format = reply.format };
|
||||
}
|
||||
|
||||
/// Composite the dirty layers and flush the frame to the screen.
|
||||
pub fn present() bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.present) }, &reply);
|
||||
return transact(.present, 0, {}, &.{}) != null;
|
||||
}
|
||||
|
||||
/// One selectable display mode.
|
||||
@@ -65,23 +85,17 @@ pub const Mode = display_protocol.Mode;
|
||||
/// Fill `out` with the resolutions the display can switch to; returns how many were written
|
||||
/// (zero on the GOP floor, or if the service never came up).
|
||||
pub fn modes(out: []Mode) usize {
|
||||
const h = service() orelse return 0;
|
||||
var request = display_protocol.Request{ .operation = @intFromEnum(display_protocol.Operation.get_modes) };
|
||||
var reply: [display_protocol.modes_reply_size]u8 = undefined;
|
||||
const len = ipc.call(h, std.mem.asBytes(&request), &reply) catch return 0;
|
||||
if (len < display_protocol.modes_reply_size) return 0;
|
||||
const answer = std.mem.bytesToValue(display_protocol.ModesReply, reply[0..display_protocol.modes_reply_size]);
|
||||
if (answer.status != 0) return 0;
|
||||
const count = @min(@min(answer.count, display_protocol.max_modes), out.len);
|
||||
for (0..count) |i| out[i] = answer.modes[i];
|
||||
const answered = transact(.get_modes, 0, {}, &.{}) orelse return 0;
|
||||
const offered = Protocol.decodeReply(.get_modes, answered.bytes()) orelse return 0;
|
||||
const count = @min(@min(offered.count, display_protocol.max_modes), out.len);
|
||||
for (0..count) |i| out[i] = offered.modes[i];
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Change the display resolution. Only a native backend that supports mode-setting honours it
|
||||
/// (on the GOP floor it returns false); on success the display's `info()` reports the new mode.
|
||||
pub fn setMode(width: u32, height: u32) bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
const changed = transact(.{ .operation = @intFromEnum(display_protocol.Operation.set_mode), .width = width, .height = height }, &reply);
|
||||
const changed = transact(.set_mode, 0, .{ .width = width, .height = height }, &.{}) != null;
|
||||
if (changed) mode = null; // the cached mode is stale now
|
||||
return changed;
|
||||
}
|
||||
@@ -106,93 +120,46 @@ pub fn color(r: u8, g: u8, b: u8) u32 {
|
||||
/// A handle to a server-owned layer: a positioned, z-ordered surface the client draws
|
||||
/// into by command. Create with `createLayer`; drawing and moves take effect on the next
|
||||
/// `present`. Coordinates are signed (a layer may sit partly off-screen).
|
||||
///
|
||||
/// The id is the packet header's `target` on every call below, so it is named once
|
||||
/// per request rather than repeated inside one.
|
||||
pub const Layer = struct {
|
||||
id: u32,
|
||||
|
||||
/// Fill a rectangle of this layer (layer-local coordinates) with a native `colour`.
|
||||
pub fn fill(self: Layer, x: i32, y: i32, w: u32, h: u32, colour: u32) bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
return transact(.{
|
||||
.operation = @intFromEnum(display_protocol.Operation.fill_rect),
|
||||
.layer = self.id,
|
||||
.x = @bitCast(x),
|
||||
.y = @bitCast(y),
|
||||
.width = w,
|
||||
.height = h,
|
||||
.colour = colour,
|
||||
}, &reply);
|
||||
return transact(.fill_rect, self.id, .{ .x = x, .y = y, .width = w, .height = h, .colour = colour }, &.{}) != null;
|
||||
}
|
||||
|
||||
/// Copy a `w`×`h` tile of native pixels (row-major, little-endian bytes) into this
|
||||
/// layer at (`x`, `y`). The tile rides inline in the request, so `w*h*4` must fit
|
||||
/// `display_protocol.maximum_payload`.
|
||||
/// layer at (`x`, `y`). The tile rides inline as the request's tail, so `w*h*4` must
|
||||
/// fit `display_protocol.maximum_payload` — the bound the protocol derives from this
|
||||
/// verb's own fixed part, so the check here can never drift from what fits.
|
||||
pub fn blitTile(self: Layer, x: i32, y: i32, w: u32, h: u32, pixels: []const u8) bool {
|
||||
var request = display_protocol.Request{
|
||||
.operation = @intFromEnum(display_protocol.Operation.blit_tile),
|
||||
.layer = self.id,
|
||||
.x = @bitCast(x),
|
||||
.y = @bitCast(y),
|
||||
.width = w,
|
||||
.height = h,
|
||||
};
|
||||
const header = std.mem.asBytes(&request);
|
||||
if (header.len + pixels.len > display_protocol.message_maximum) return false;
|
||||
var buffer: [display_protocol.message_maximum]u8 = undefined;
|
||||
@memcpy(buffer[0..header.len], header);
|
||||
@memcpy(buffer[header.len..][0..pixels.len], pixels);
|
||||
const h_svc = service() orelse return false;
|
||||
var reply: [display_protocol.reply_size]u8 = undefined;
|
||||
const len = ipc.call(h_svc, buffer[0 .. header.len + pixels.len], &reply) catch return false;
|
||||
if (len < display_protocol.reply_size) return false;
|
||||
return std.mem.bytesToValue(display_protocol.Reply, reply[0..display_protocol.reply_size]).status == 0;
|
||||
if (pixels.len > display_protocol.maximum_payload) return false;
|
||||
return transact(.blit_tile, self.id, .{ .x = x, .y = y, .width = w, .height = h }, pixels) != null;
|
||||
}
|
||||
|
||||
/// Move / restack / show or hide the layer.
|
||||
pub fn configure(self: Layer, x: i32, y: i32, z: u32, visible: bool) bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
return transact(.{
|
||||
.operation = @intFromEnum(display_protocol.Operation.configure_layer),
|
||||
.layer = self.id,
|
||||
.x = @bitCast(x),
|
||||
.y = @bitCast(y),
|
||||
.z = z,
|
||||
.visible = if (visible) 1 else 0,
|
||||
}, &reply);
|
||||
return transact(.configure_layer, self.id, .{ .x = x, .y = y, .z = z, .visible = if (visible) 1 else 0 }, &.{}) != null;
|
||||
}
|
||||
|
||||
/// Mark a rectangle of this layer (layer-local) dirty for the next present — for when
|
||||
/// the layer's pixels changed without a drawing call the compositor already tracked.
|
||||
pub fn damage(self: Layer, x: i32, y: i32, w: u32, h: u32) bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
return transact(.{
|
||||
.operation = @intFromEnum(display_protocol.Operation.damage),
|
||||
.layer = self.id,
|
||||
.x = @bitCast(x),
|
||||
.y = @bitCast(y),
|
||||
.width = w,
|
||||
.height = h,
|
||||
}, &reply);
|
||||
return transact(.damage, self.id, .{ .x = x, .y = y, .width = w, .height = h }, &.{}) != null;
|
||||
}
|
||||
|
||||
/// Release the layer and its surface.
|
||||
pub fn destroy(self: Layer) bool {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
return transact(.{ .operation = @intFromEnum(display_protocol.Operation.destroy_layer), .layer = self.id }, &reply);
|
||||
return transact(.destroy_layer, self.id, {}, &.{}) != null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Create a server-owned layer of `w`×`h` pixels at screen (`x`, `y`) with stacking order
|
||||
/// `z` (higher is nearer the front), initially visible. Returns a handle, or null.
|
||||
pub fn createLayer(x: i32, y: i32, w: u32, h: u32, z: u32) ?Layer {
|
||||
var reply: display_protocol.Reply = undefined;
|
||||
if (!transact(.{
|
||||
.operation = @intFromEnum(display_protocol.Operation.create_layer),
|
||||
.x = @bitCast(x),
|
||||
.y = @bitCast(y),
|
||||
.width = w,
|
||||
.height = h,
|
||||
.z = z,
|
||||
.visible = 1,
|
||||
}, &reply)) return null;
|
||||
return .{ .id = reply.layer };
|
||||
const answered = transact(.create_layer, 0, .{ .x = x, .y = y, .width = w, .height = h, .z = z, .visible = 1 }, &.{}) orelse return null;
|
||||
return .{ .id = (Protocol.decodeReply(.create_layer, answered.bytes()) orelse return null).layer };
|
||||
}
|
||||
|
||||
@@ -21,12 +21,14 @@
|
||||
//! if (event.asKeyboard()) |k| { ... } else if (event.asMouse()) |m| { ... }
|
||||
//! }
|
||||
|
||||
const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const input_protocol = @import("input-protocol");
|
||||
|
||||
const Protocol = input_protocol.Protocol;
|
||||
|
||||
pub const DeviceKind = input_protocol.DeviceKind;
|
||||
pub const InputEvent = input_protocol.InputEvent;
|
||||
pub const KeyEvent = input_protocol.KeyEvent;
|
||||
@@ -44,13 +46,13 @@ pub const device_mouse = input_protocol.device_mouse;
|
||||
pub const device_joystick = input_protocol.device_joystick;
|
||||
pub const device_all = input_protocol.device_all;
|
||||
|
||||
/// Look up the input service, retrying while it is still coming up. Both a subscriber and
|
||||
/// a source race the service's registration at boot, so both wait for it here rather than
|
||||
/// failing. Returns the service endpoint handle, or null if it never appears.
|
||||
/// Open `/protocol/input`, retrying while it is still coming up. Both a subscriber and
|
||||
/// a source race the service's bind at boot, so both wait for it here rather than
|
||||
/// failing. Returns the provider's endpoint handle, or null if it never appears.
|
||||
fn lookupService() ?ipc.Handle {
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.input)) |handle| return handle;
|
||||
if (channel.openEndpoint("input")) |handle| return handle;
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
@@ -66,31 +68,44 @@ pub const Subscriber = struct {
|
||||
/// The endpoint the service delivers events to (created and owned by us; its handle
|
||||
/// was handed to the service as a capability at subscribe time).
|
||||
endpoint: ipc.Handle,
|
||||
receive: [input_protocol.event_size]u8 = undefined,
|
||||
/// A pushed packet is the folded header plus one typed event, so the buffer is
|
||||
/// the push floor rather than any one event's size.
|
||||
receive: [envelope.post_maximum]u8 = undefined,
|
||||
|
||||
/// Block until the next event is pushed, and return it. Events arrive as asynchronous
|
||||
/// buffered messages (`ipc_send` from the service), so nothing is owed in reply — the
|
||||
/// empty reply this issues is a harmless no-op. Returns null for any non-event wake-up
|
||||
/// (there should be none), so callers can loop.
|
||||
///
|
||||
/// The device class is the packet's operation, so it is read from the header and
|
||||
/// re-tagged into an `InputEvent` here — one decoded type for a caller that took
|
||||
/// several classes on one stream.
|
||||
pub fn next(self: *Subscriber) ?InputEvent {
|
||||
const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null);
|
||||
if (!got.isMessage() or got.len < input_protocol.event_size) return null;
|
||||
return std.mem.bytesToValue(InputEvent, self.receive[0..input_protocol.event_size]);
|
||||
if (!got.isMessage()) return null;
|
||||
const packet = self.receive[0..@min(got.len, self.receive.len)];
|
||||
return switch (Protocol.eventOf(packet) orelse return null) {
|
||||
.keyboard => InputEvent.fromKeyboard(Protocol.decodeEvent(.keyboard, packet) orelse return null),
|
||||
.mouse => InputEvent.fromMouse(Protocol.decodeEvent(.mouse, packet) orelse return null),
|
||||
.joystick => InputEvent.fromJoystick(Protocol.decodeEvent(.joystick, packet) orelse return null),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Subscribe to the input classes named in `device_mask` (an OR of `device_*`, or
|
||||
/// `device_all`). Creates an endpoint for the service to push to and hands it over as a
|
||||
/// capability. Returns a `Subscriber` to loop `next` on, or null on failure.
|
||||
/// capability — the envelope's reserved `subscribe`, whose shape this is exactly. Returns
|
||||
/// a `Subscriber` to loop `next` on, or null on failure.
|
||||
pub fn subscribe(device_mask: u32) ?Subscriber {
|
||||
const service = lookupService() orelse return null;
|
||||
const endpoint = ipc.createIpcEndpoint() orelse return null;
|
||||
|
||||
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.subscribe), .device_mask = device_mask };
|
||||
var reply: [input_protocol.reply_size]u8 = undefined;
|
||||
const result = ipc.callCap(service, std.mem.asBytes(&request), &reply, endpoint) catch return null;
|
||||
if (result.len < input_protocol.reply_size) return null;
|
||||
if (std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status != 0) return null;
|
||||
var packet: [input_protocol.message_maximum]u8 = undefined;
|
||||
const framed = input_protocol.encodeSubscribe(device_mask, &packet) orelse return null;
|
||||
var reply: [input_protocol.message_maximum]u8 = undefined;
|
||||
const result = ipc.callCap(service, framed, &reply, endpoint) catch return null;
|
||||
const status = envelope.statusOf(reply[0..result.len]) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
return .{ .endpoint = endpoint };
|
||||
}
|
||||
|
||||
@@ -149,11 +164,12 @@ pub const Publisher = struct {
|
||||
service: ipc.Handle,
|
||||
|
||||
fn publish(self: Publisher, event: InputEvent) bool {
|
||||
var request = input_protocol.Request{ .operation = @intFromEnum(input_protocol.Operation.publish), .event = event };
|
||||
var reply: [input_protocol.reply_size]u8 = undefined;
|
||||
const len = ipc.call(self.service, std.mem.asBytes(&request), &reply) catch return false;
|
||||
if (len < input_protocol.reply_size) return false;
|
||||
return std.mem.bytesToValue(input_protocol.Reply, reply[0..input_protocol.reply_size]).status == 0;
|
||||
var packet: [input_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(.publish, 0, event, &.{}, &packet) orelse return false;
|
||||
var reply: [input_protocol.message_maximum]u8 = undefined;
|
||||
const len = ipc.call(self.service, framed, &reply) catch return false;
|
||||
const status = envelope.statusOf(reply[0..len]) orelse return false;
|
||||
return status.status == 0;
|
||||
}
|
||||
|
||||
/// Broadcast a keyboard event to every subscriber that took keyboard events.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! The "csv" library domain: shared CSV helpers (comment stripping, field
|
||||
//! iteration) for the /etc/*.csv config files — the device registry and the
|
||||
//! iteration) for the /system/configuration/*.csv config files — the device registry and the
|
||||
//! init service list both parse them.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
//! Minimal CSV helpers shared by the `/etc/*.csv` config files — the device
|
||||
//! registry (`/etc/devices.csv`) and the init service list (`/etc/init.csv`).
|
||||
//! Minimal CSV helpers shared by the `/system/configuration/*.csv` config files — the device
|
||||
//! registry (`/system/configuration/devices.csv`) and the init service list (`/system/configuration/init.csv`).
|
||||
//! Freestanding, no allocator: returned fields are slices into the source line,
|
||||
//! so the source must outlive them. `#` starts a comment (whole-line or trailing);
|
||||
//! whitespace around a field is trimmed, so columns may be padded for alignment.
|
||||
|
||||
@@ -7,11 +7,14 @@
|
||||
//! `runtime.dma.alloc`), so whole sectors move without crossing the IPC size
|
||||
//! limit — the same handoff usb-storage uses toward the controller.
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const block_protocol = @import("block-protocol");
|
||||
|
||||
const Protocol = block_protocol.Protocol;
|
||||
|
||||
pub const Geometry = struct { block_size: u32, block_count: u64 };
|
||||
|
||||
pub const Device = struct {
|
||||
@@ -19,12 +22,9 @@ pub const Device = struct {
|
||||
|
||||
/// The device's block size and total block count.
|
||||
pub fn geometry(self: Device) ?Geometry {
|
||||
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.geometry), .lba = 0, .count = 0, .physical = 0 };
|
||||
var reply: [block_protocol.reply_size]u8 = undefined;
|
||||
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return null;
|
||||
if (n < block_protocol.reply_size) return null;
|
||||
const result = std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]);
|
||||
if (result.status != 0) return null;
|
||||
var reply: [block_protocol.message_maximum]u8 = undefined;
|
||||
const answered = self.call(.geometry, {}, null, &reply) orelse return null;
|
||||
const result = Protocol.decodeReply(.geometry, answered) orelse return null;
|
||||
return .{ .block_size = result.block_size, .block_count = result.block_count };
|
||||
}
|
||||
|
||||
@@ -33,47 +33,56 @@ pub const Device = struct {
|
||||
/// addresses become reachable by the device. Call once per buffer before naming it
|
||||
/// in `read`/`write`. Harmless success when no IOMMU is enforcing.
|
||||
pub fn attach(self: Device, handle: ipc.Handle) bool {
|
||||
var request = block_protocol.Request{ .operation = @intFromEnum(block_protocol.Operation.attach), .lba = 0, .count = 0, .physical = 0 };
|
||||
var reply: [block_protocol.reply_size]u8 = undefined;
|
||||
const result = ipc.callCap(self.endpoint, std.mem.asBytes(&request), &reply, handle) catch return false;
|
||||
if (result.len < block_protocol.reply_size) return false;
|
||||
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
|
||||
var reply: [block_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.attach, {}, handle, &reply) != null;
|
||||
}
|
||||
|
||||
/// Read `count` blocks starting at `lba` into the DMA buffer at `physical`.
|
||||
pub fn read(self: Device, lba: u64, count: u32, physical: u64) bool {
|
||||
return self.transfer(.read, lba, count, physical);
|
||||
var reply: [block_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.read, .{ .lba = lba, .count = count, .physical = physical }, null, &reply) != null;
|
||||
}
|
||||
|
||||
/// Write `count` blocks starting at `lba` from the DMA buffer at `physical`.
|
||||
pub fn write(self: Device, lba: u64, count: u32, physical: u64) bool {
|
||||
return self.transfer(.write, lba, count, physical);
|
||||
var reply: [block_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.write, .{ .lba = lba, .count = count, .physical = physical }, null, &reply) != null;
|
||||
}
|
||||
|
||||
/// Commit any device write cache to stable media (SCSI SYNCHRONIZE CACHE), so
|
||||
/// prior writes survive a power-off. A filesystem calls this before the machine
|
||||
/// goes down; no data transfer, so the buffer arguments are unused.
|
||||
pub fn flush(self: Device) bool {
|
||||
return self.transfer(.flush, 0, 0, 0);
|
||||
var reply: [block_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.flush, {}, null, &reply) != null;
|
||||
}
|
||||
|
||||
fn transfer(self: Device, operation: block_protocol.Operation, lba: u64, count: u32, physical: u64) bool {
|
||||
var request = block_protocol.Request{ .operation = @intFromEnum(operation), .lba = lba, .count = count, .physical = physical };
|
||||
var reply: [block_protocol.reply_size]u8 = undefined;
|
||||
const n = ipc.call(self.endpoint, std.mem.asBytes(&request), &reply) catch return false;
|
||||
if (n < block_protocol.reply_size) return false;
|
||||
return std.mem.bytesToValue(block_protocol.Reply, reply[0..block_protocol.reply_size]).status == 0;
|
||||
/// One request at the driver. `target` is always 0: one endpoint per device, so
|
||||
/// there is no object within the peer to address.
|
||||
fn call(
|
||||
self: Device,
|
||||
comptime operation: Protocol.Operation,
|
||||
request: Protocol.RequestOf(operation),
|
||||
capability: ?ipc.Handle,
|
||||
reply: []u8,
|
||||
) ?[]u8 {
|
||||
var packet: [block_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(operation, 0, request, &.{}, &packet) orelse return null;
|
||||
const answer = ipc.callCap(self.endpoint, framed, reply, capability) catch return null;
|
||||
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
return reply[0..answer.len];
|
||||
}
|
||||
};
|
||||
|
||||
/// One lookup attempt, no waiting — for a server that retries on its own
|
||||
/// One open attempt, no waiting — for a server that retries on its own
|
||||
/// timer (the fat service) instead of blocking its harness in here.
|
||||
pub fn tryOpen() ?Device {
|
||||
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
|
||||
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Look up the block device, retrying generously while the USB storage chain
|
||||
/// Open `/protocol/block`, retrying generously while the USB storage chain
|
||||
/// (controller reset, enumeration, mass-storage bring-up) comes up.
|
||||
pub fn open() ?Device {
|
||||
// Patient: the whole USB storage chain (firmware discovery, xHCI reset and
|
||||
@@ -84,7 +93,7 @@ pub fn open() ?Device {
|
||||
// completed at ~24 s); a machine whose stick genuinely failed setup should
|
||||
// not sit a further minute pretending otherwise.
|
||||
while (attempts < 600) : (attempts += 1) {
|
||||
if (ipc.lookup(.block)) |handle| return .{ .endpoint = handle };
|
||||
if (channel.openEndpoint("block")) |handle| return .{ .endpoint = handle };
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -14,6 +14,10 @@ pub fn build(b: *std.Build) void {
|
||||
const system_call = kernel.module("system-call");
|
||||
const ipc = kernel.module("ipc");
|
||||
const time = kernel.module("time");
|
||||
// A driver finds the bus it attaches to by name — `/protocol/device-manager`,
|
||||
// `/protocol/usb-transfer`, `/protocol/block`
|
||||
// (docs/os-development/protocol-namespace.md).
|
||||
const channel = kernel.module("channel");
|
||||
|
||||
// The devices sub-project's public interface (the flat wire types),
|
||||
// importable by user space, unlike the kernel-internal device model it
|
||||
@@ -55,7 +59,9 @@ pub fn build(b: *std.Build) void {
|
||||
.root_source_file = b.path("driver/driver.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "device-abi", .module = device_abi },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
@@ -81,6 +87,8 @@ pub fn build(b: *std.Build) void {
|
||||
_ = b.addModule("usb", .{
|
||||
.root_source_file = b.path("usb/usb.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "usb-transfer-protocol", .module = protocol.module("usb-transfer-protocol") },
|
||||
@@ -92,12 +100,14 @@ pub fn build(b: *std.Build) void {
|
||||
_ = b.addModule("block", .{
|
||||
.root_source_file = b.path("block/block.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "block-protocol", .module = protocol.module("block-protocol") },
|
||||
},
|
||||
});
|
||||
// The device registry: parse /etc/devices.csv into match rules and bind a
|
||||
// The device registry: parse /system/configuration/devices.csv into match rules and bind a
|
||||
// reported device to a driver. Pure logic (no hardware, no syscalls), so it
|
||||
// unit-tests on the host; the device manager imports it.
|
||||
_ = b.addModule("device-registry", .{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
.kernel = .{ .path = "../kernel" },
|
||||
// driver speaks device-manager-protocol; block/usb their transfer protocols.
|
||||
.protocol = .{ .path = "../protocol" },
|
||||
// device-registry parses /etc/devices.csv with the shared csv helpers.
|
||||
// device-registry parses /system/configuration/devices.csv with the shared csv helpers.
|
||||
.csv = .{ .path = "../csv" },
|
||||
},
|
||||
.paths = .{""},
|
||||
|
||||
@@ -7,6 +7,8 @@ const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
const device_abi = @import("device-abi");
|
||||
const sc = @import("system-call");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
@@ -167,25 +169,38 @@ const lookup_pause_ms: u64 = 20;
|
||||
/// (best-effort standalone bring-up) or it refused the handshake. Bus drivers keep the handle
|
||||
/// to report children through; a driver that runs fine unsupervised discards it with `_ =`,
|
||||
/// and one that requires supervision bails on null. Logs the outcome itself.
|
||||
///
|
||||
/// The device this driver was assigned is the packet's `Header.target` — the manager's
|
||||
/// object addressing, so `no_device` here is a driver that serves none.
|
||||
pub fn hello(role: Role, device_id: u64) ?ipc.Handle {
|
||||
var attempts: u32 = 0;
|
||||
const manager = while (attempts < lookup_attempts) : (attempts += 1) {
|
||||
if (ipc.lookup(.device_manager)) |handle| break handle;
|
||||
if (channel.openEndpoint("device-manager")) |handle| break handle;
|
||||
time.sleepMillis(lookup_pause_ms);
|
||||
} else {
|
||||
std.log.info("no device manager to hello", .{});
|
||||
return null;
|
||||
};
|
||||
|
||||
const message = device_manager_protocol.Hello{ .role = @intFromEnum(role), .device_id = device_id };
|
||||
var reply: [device_manager_protocol.reply_size]u8 = undefined;
|
||||
const length = ipc.call(manager, std.mem.asBytes(&message), &reply) catch {
|
||||
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
const framed = device_manager_protocol.Protocol.encodeRequest(
|
||||
.hello,
|
||||
device_id,
|
||||
.{ .role = @intFromEnum(role) },
|
||||
&.{},
|
||||
&packet,
|
||||
) orelse return null;
|
||||
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
const length = ipc.call(manager, framed, &reply) catch {
|
||||
std.log.info("hello call failed", .{});
|
||||
return null;
|
||||
};
|
||||
if (length < device_manager_protocol.reply_size or
|
||||
std.mem.bytesToValue(device_manager_protocol.HelloReply, reply[0..device_manager_protocol.reply_size]).status != 0)
|
||||
{
|
||||
const status = envelope.statusOf(reply[0..length]) orelse {
|
||||
std.log.info("hello answered nothing readable", .{});
|
||||
return null;
|
||||
};
|
||||
if (status.status != 0) {
|
||||
std.log.info("hello refused", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ pub const DeviceDescriptor = extern struct {
|
||||
// names with the pci-class module.
|
||||
pci_class: u64,
|
||||
// Numeric identity beyond the class triple, mirrored in the bus report's
|
||||
// ChildAdded so /etc/devices.csv can bind on it: `vendor`/`device` are the PCI
|
||||
// ChildAdded so /system/configuration/devices.csv can bind on it: `vendor`/`device` are the PCI
|
||||
// vendor/device (or USB idVendor/idProduct), `subsystem` is the PCI subsystem id
|
||||
// packed `(subsystem_vendor << 16) | subsystem_device`. Zero where the bus has no
|
||||
// such concept. Defaulted so existing descriptor literals keep compiling and lay
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! The device registry: parse `/etc/devices.csv` into match rules and bind a
|
||||
//! The device registry: parse `/system/configuration/devices.csv` into match rules and bind a
|
||||
//! reported device to a driver. This is the data-driven replacement for the
|
||||
//! device manager's three hand-written `switch` tables (`pciDriverForIdentity`,
|
||||
//! `hidDriverFor`, `usbDriverForIdentity`); the registry is now **authoritative**
|
||||
@@ -11,7 +11,7 @@
|
||||
//! That keeps this module freestanding and unit-testable with plain `zig test`.
|
||||
//!
|
||||
//! The file format (docs/device-driver-development/device-manager.md, and the
|
||||
//! `/etc/devices.csv` header itself): one rule per line, nine comma-separated
|
||||
//! `/system/configuration/devices.csv` header itself): one rule per line, nine comma-separated
|
||||
//! fields, `#` starts a comment (whole-line or trailing), blank lines ignored.
|
||||
//!
|
||||
//! bus, base, class, prog_if, vendor, device, subsystem, hid, driver
|
||||
@@ -226,7 +226,7 @@ fn parseLine(line: []const u8) Line {
|
||||
} };
|
||||
}
|
||||
|
||||
/// Parse a whole `/etc/devices.csv` into `out_rules`. The string fields of the
|
||||
/// Parse a whole `/system/configuration/devices.csv` into `out_rules`. The string fields of the
|
||||
/// returned rules point into `source`, which must outlive them.
|
||||
pub fn parse(source: []const u8, out_rules: []Rule) ParseResult {
|
||||
var result: ParseResult = .{ .count = 0, .malformed = 0, .truncated = false };
|
||||
|
||||
+79
-45
@@ -11,15 +11,24 @@
|
||||
//! _ = device.subscribeInterrupt(address, length); // reports arrive asynchronously
|
||||
//! while (true) { ... ipc.replyWait(device.endpoint, ...) ... } // its own loop
|
||||
//!
|
||||
//! Reports are delivered to `device.endpoint` as asynchronous `InterruptReport`
|
||||
//! messages (the class driver runs a bare `replyWait` loop to read them, because
|
||||
//! the service harness drops buffered-message payloads — see service.zig).
|
||||
//! Reports are delivered to `device.endpoint` as asynchronous `interrupt_report`
|
||||
//! event packets, decoded with `reportOf` (the class driver runs a bare `replyWait`
|
||||
//! loop to read them, because the service harness drops buffered-message payloads
|
||||
//! — see service.zig).
|
||||
//!
|
||||
//! Every packet this file lays down is an envelope packet: the verb and the
|
||||
//! device token in the folded `Header`, the transfer's own fields after it, and
|
||||
//! a control transfer's data stage in the tail.
|
||||
|
||||
const std = @import("std");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
const usb_transfer_protocol = @import("usb-transfer-protocol");
|
||||
|
||||
const Protocol = usb_transfer_protocol.Protocol;
|
||||
|
||||
/// The USB chapter-9 wire ABI and the class taxonomy, re-exported so a class driver reaches
|
||||
/// the whole USB domain through its one `usb` import (`usb.abi.getDescriptor`, `usb.ids.Class`).
|
||||
pub const abi = @import("usb-abi");
|
||||
@@ -56,21 +65,41 @@ pub const Device = struct {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// One request at the bus driver, addressing this device by its token — the
|
||||
/// packet's `Header.target`, so no request body ever names the device again.
|
||||
/// Null covers both a failed transport and a refusal: a class driver has the
|
||||
/// same recourse either way.
|
||||
fn call(
|
||||
self: *Device,
|
||||
comptime operation: Protocol.Operation,
|
||||
request: Protocol.RequestOf(operation),
|
||||
tail: []const u8,
|
||||
capability: ?ipc.Handle,
|
||||
reply: []u8,
|
||||
) ?[]u8 {
|
||||
var packet: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(operation, self.token, request, tail, &packet) orelse return null;
|
||||
const answer = ipc.callCap(self.bus, framed, reply, capability) catch return null;
|
||||
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
return reply[0..answer.len];
|
||||
}
|
||||
|
||||
/// The data stage rides the tail in both directions, so the answer's length
|
||||
/// *is* the transferred length — `Status.len`, which the envelope stamps.
|
||||
fn controlTransfer(self: *Device, setup: [8]u8, direction_in: bool, data: []u8) ?usize {
|
||||
var request = usb_transfer_protocol.ControlRequest{
|
||||
.device_token = self.token,
|
||||
if (data.len > usb_transfer_protocol.max_inline_data) return null;
|
||||
const outgoing: []const u8 = if (direction_in) &.{} else data;
|
||||
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
const answered = self.call(.control, .{
|
||||
.setup = setup,
|
||||
.direction_in = @intFromBool(direction_in),
|
||||
.data_length = @intCast(data.len),
|
||||
};
|
||||
if (!direction_in and data.len > 0) @memcpy(request.data[0..data.len], data);
|
||||
var reply: [@sizeOf(usb_transfer_protocol.ControlReply)]u8 = undefined;
|
||||
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
|
||||
if (length < @sizeOf(usb_transfer_protocol.ControlReply)) return null;
|
||||
const control_reply = std.mem.bytesToValue(usb_transfer_protocol.ControlReply, reply[0..@sizeOf(usb_transfer_protocol.ControlReply)]);
|
||||
if (control_reply.status != 0) return null;
|
||||
const actual = @min(control_reply.actual_length, data.len);
|
||||
if (direction_in and actual > 0) @memcpy(data[0..actual], control_reply.data[0..actual]);
|
||||
}, outgoing, null, &reply) orelse return null;
|
||||
|
||||
const returned = Protocol.replyTail(.control, answered);
|
||||
const actual = @min(returned.len, data.len);
|
||||
if (direction_in and actual > 0) @memcpy(data[0..actual], returned[0..actual]);
|
||||
return actual;
|
||||
}
|
||||
|
||||
@@ -88,15 +117,11 @@ pub const Device = struct {
|
||||
/// Begin periodic IN polling of an interrupt endpoint; reports flow back to
|
||||
/// `self.endpoint` as asynchronous `InterruptReport` messages.
|
||||
pub fn subscribeInterrupt(self: *Device, endpoint_address: u8, max_length: u16) bool {
|
||||
var request = usb_transfer_protocol.InterruptSubscribeRequest{
|
||||
.device_token = self.token,
|
||||
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.interrupt_subscribe, .{
|
||||
.endpoint_address = endpoint_address,
|
||||
.max_length = max_length,
|
||||
};
|
||||
var reply: [@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]u8 = undefined;
|
||||
const length = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return false;
|
||||
if (length < @sizeOf(usb_transfer_protocol.InterruptSubscribeReply)) return false;
|
||||
return std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeReply, reply[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeReply)]).status == 0;
|
||||
}, &.{}, null, &reply) != null;
|
||||
}
|
||||
|
||||
/// Hand the controller a DMA-region capability (`handle` — from a `shareable`
|
||||
@@ -105,48 +130,57 @@ pub const Device = struct {
|
||||
/// will name in a `bulk` transfer, before the transfer. Harmless (and a no-op
|
||||
/// success) when no IOMMU is enforcing. Returns false on failure.
|
||||
pub fn attachDma(self: *Device, handle: ipc.Handle) bool {
|
||||
var request = usb_transfer_protocol.DmaAttachRequest{ .device_token = self.token };
|
||||
var reply: [@sizeOf(usb_transfer_protocol.DmaAttachReply)]u8 = undefined;
|
||||
const result = ipc.callCap(self.bus, std.mem.asBytes(&request), &reply, handle) catch return false;
|
||||
if (result.len < @sizeOf(usb_transfer_protocol.DmaAttachReply)) return false;
|
||||
return std.mem.bytesToValue(usb_transfer_protocol.DmaAttachReply, reply[0..@sizeOf(usb_transfer_protocol.DmaAttachReply)]).status == 0;
|
||||
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
return self.call(.dma_attach, {}, &.{}, handle, &reply) != null;
|
||||
}
|
||||
|
||||
/// One bulk transfer (IN or OUT per `endpoint_address`'s direction bit) to or
|
||||
/// from the caller's own DMA buffer at `physical`. Returns the bytes moved.
|
||||
pub fn bulk(self: *Device, endpoint_address: u8, physical: u64, length: u32) ?u32 {
|
||||
var request = usb_transfer_protocol.BulkRequest{
|
||||
.device_token = self.token,
|
||||
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
const answered = self.call(.bulk, .{
|
||||
.physical_address = physical,
|
||||
.length = length,
|
||||
.endpoint_address = endpoint_address,
|
||||
};
|
||||
var reply: [@sizeOf(usb_transfer_protocol.BulkReply)]u8 = undefined;
|
||||
const replied = ipc.call(self.bus, std.mem.asBytes(&request), &reply) catch return null;
|
||||
if (replied < @sizeOf(usb_transfer_protocol.BulkReply)) return null;
|
||||
const bulk_reply = std.mem.bytesToValue(usb_transfer_protocol.BulkReply, reply[0..@sizeOf(usb_transfer_protocol.BulkReply)]);
|
||||
if (bulk_reply.status != 0) return null;
|
||||
return bulk_reply.actual_length;
|
||||
}, &.{}, null, &reply) orelse return null;
|
||||
return (Protocol.decodeReply(.bulk, answered) orelse return null).actual_length;
|
||||
}
|
||||
};
|
||||
|
||||
/// Look up the USB bus and open the device with the assigned id, handing over a
|
||||
/// freshly created endpoint for asynchronous interrupt reports. Retries while the
|
||||
/// bus is still coming up (a class driver races the bus driver at boot).
|
||||
/// Decode one asynchronous interrupt report out of a packet that arrived on the
|
||||
/// class driver's own endpoint. Null when it is not one — a stray message, or a
|
||||
/// packet too short to carry the report it names. The device it came from is the
|
||||
/// packet's `Header.target`, which a single-device class driver never has to read.
|
||||
pub fn reportOf(packet: []const u8) ?InterruptReport {
|
||||
const event = Protocol.eventOf(packet) orelse return null;
|
||||
if (event != .interrupt_report) return null;
|
||||
return Protocol.decodeEvent(.interrupt_report, packet);
|
||||
}
|
||||
|
||||
/// Open `/protocol/usb-transfer` and, on that channel, open the device with the
|
||||
/// assigned id, handing over a freshly created endpoint for asynchronous interrupt
|
||||
/// reports. Retries while the bus is still coming up (a class driver races the bus
|
||||
/// driver at boot). Two opens, deliberately: the first names the contract, the
|
||||
/// second names an object within it.
|
||||
pub fn open(device_id: u64) ?Device {
|
||||
var attempts: usize = 0;
|
||||
const bus = while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.usb_bus)) |handle| break handle;
|
||||
if (channel.openEndpoint("usb-transfer")) |handle| break handle;
|
||||
time.sleepMillis(20);
|
||||
} else return null;
|
||||
|
||||
const endpoint = ipc.createIpcEndpoint() orelse return null;
|
||||
var request = usb_transfer_protocol.OpenRequest{ .device_id = device_id };
|
||||
var reply: [@sizeOf(usb_transfer_protocol.OpenReply)]u8 = undefined;
|
||||
const result = ipc.callCap(bus, std.mem.asBytes(&request), &reply, endpoint) catch return null;
|
||||
if (result.len < @sizeOf(usb_transfer_protocol.OpenReply)) return null;
|
||||
const open_reply = std.mem.bytesToValue(usb_transfer_protocol.OpenReply, reply[0..@sizeOf(usb_transfer_protocol.OpenReply)]);
|
||||
if (open_reply.status != 0) return null;
|
||||
// The assigned device id is the target: it is what the caller has before a
|
||||
// token exists, and the token the reply hands back addresses every packet
|
||||
// after this one.
|
||||
var packet: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(.open, device_id, {}, &.{}, &packet) orelse return null;
|
||||
var reply: [usb_transfer_protocol.message_maximum]u8 = undefined;
|
||||
const result = ipc.callCap(bus, framed, &reply, endpoint) catch return null;
|
||||
const answered = reply[0..result.len];
|
||||
const status = envelope.statusOf(answered) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
const open_reply = Protocol.decodeReply(.open, answered) orelse return null;
|
||||
|
||||
var device = Device{
|
||||
.bus = bus,
|
||||
|
||||
@@ -52,13 +52,28 @@ 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 },
|
||||
.{ .name = "system-call", .module = system_call },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
});
|
||||
// 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.
|
||||
const channel = b.addModule("channel", .{
|
||||
.root_source_file = b.path("channel.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "time", .module = time },
|
||||
.{ .name = "file-system", .module = file_system },
|
||||
.{ .name = "vfs-protocol", .module = protocol.module("vfs-protocol") },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
},
|
||||
});
|
||||
_ = b.addModule("memory", .{
|
||||
@@ -70,10 +85,16 @@ pub fn build(b: *std.Build) void {
|
||||
.{ .name = "thread", .module = thread },
|
||||
},
|
||||
});
|
||||
// The harness binds the service's contract name at startup, which is a
|
||||
// conversation with the registry — hence channel (and time, for the patience
|
||||
// a provider that beat init to the mount needs). It also owns the subscriber
|
||||
// table and the fan-out, which are expressed in the envelope's vocabulary
|
||||
// (the reserved subscribe verb, the push floor) — hence envelope.
|
||||
_ = b.addModule("service", .{
|
||||
.root_source_file = b.path("service.zig"),
|
||||
.imports = &.{
|
||||
.{ .name = "abi", .module = abi },
|
||||
.{ .name = "channel", .module = channel },
|
||||
.{ .name = "envelope", .module = protocol.module("envelope") },
|
||||
.{ .name = "ipc", .module = ipc },
|
||||
.{ .name = "process", .module = process },
|
||||
},
|
||||
@@ -101,4 +122,22 @@ 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 = "time", .module = time },
|
||||
.{ .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,339 @@
|
||||
//! `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. The registry
|
||||
//! answering it is init, PID 1, which mounts `/protocol` before it spawns anyone
|
||||
//! (docs/os-development/protocol-namespace.md); `bind` below is the other half —
|
||||
//! how a provider claims the name in the first place.
|
||||
|
||||
const std = @import("std");
|
||||
const ipc = @import("ipc");
|
||||
const time = @import("time");
|
||||
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;
|
||||
|
||||
/// Where the protocol namespace is rooted — the one path prefix in the system
|
||||
/// that names contracts rather than files. Spelled once, here, so no caller
|
||||
/// builds it by hand (docs/file-system-development/file-system-hierarchy.md).
|
||||
pub const root: []const u8 = "/protocol";
|
||||
|
||||
/// Longest contract name — the part after `/protocol/`. Short by construction:
|
||||
/// a leaf like `display`, or a subtree leaf like `test/shared-memory`.
|
||||
pub const name_maximum: usize = 64;
|
||||
|
||||
/// 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 {
|
||||
return .{ .endpoint = openPath(path) orelse return null };
|
||||
}
|
||||
|
||||
/// Establish a channel by contract name — `open` with `/protocol/` supplied,
|
||||
/// which is how every caller in the system spells it.
|
||||
pub fn connect(name: []const u8) ?Channel {
|
||||
return .{ .endpoint = openEndpoint(name) orelse return null };
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
};
|
||||
|
||||
// --- the namespace: resolving, opening, and claiming a contract name ---------
|
||||
|
||||
/// Where a `/protocol/...` path routed: the registry's endpoint, plus the path
|
||||
/// rewritten mount-relative (`/display` for `/protocol/display`). The handle is
|
||||
/// deduplicated by the kernel across resolves and shared with every other user
|
||||
/// of that mount, so it is never ours to close.
|
||||
const Registry = struct {
|
||||
handle: ipc.Handle,
|
||||
relative: [path_maximum]u8,
|
||||
relative_len: usize,
|
||||
|
||||
fn path(self: *const Registry) []const u8 {
|
||||
return self.relative[0..self.relative_len];
|
||||
}
|
||||
};
|
||||
|
||||
/// Route `path` to whatever backend serves it. Null when nothing is mounted
|
||||
/// there — under `/protocol` that means the registry is not up yet, which is a
|
||||
/// *retry*, not a refusal. A kernel-served route (the read-only `/system` tree)
|
||||
/// is the wrong path, not a channel, and is refused here.
|
||||
fn reach(path: []const u8) ?Registry {
|
||||
var out: Registry = .{ .handle = 0, .relative = undefined, .relative_len = 0 };
|
||||
const route = file_system.fsResolve(path, 0, &out.relative) orelse return null;
|
||||
switch (route) {
|
||||
.kernel => return null,
|
||||
.backend => |b| {
|
||||
out.handle = b.handle;
|
||||
out.relative_len = b.path_len;
|
||||
return out;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// One vfs-protocol round trip at a backend: the folded header, the verb's own
|
||||
/// fixed part, the name as the packet's tail, and an optional capability in each
|
||||
/// direction. Both verbs this file sends address the backend itself (target 0) —
|
||||
/// the name in the tail is what they are about.
|
||||
fn transact(
|
||||
comptime operation: vfs_protocol.Operation,
|
||||
handle: ipc.Handle,
|
||||
request: vfs_protocol.Protocol.RequestOf(operation),
|
||||
name: []const u8,
|
||||
send_capability: ?ipc.Handle,
|
||||
) ?struct { status: envelope.Status, capability: ?ipc.Handle } {
|
||||
var packet: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const framed = vfs_protocol.Protocol.encodeRequest(operation, 0, request, name, &packet) orelse return null;
|
||||
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answer = ipc.callCap(handle, framed, &reply, send_capability) catch return null;
|
||||
const status = envelope.statusOf(reply[0..answer.len]) orelse return null;
|
||||
return .{ .status = status, .capability = answer.cap };
|
||||
}
|
||||
|
||||
/// Resolve an absolute `/protocol/...` path and take the provider's endpoint out
|
||||
/// of the open reply's capability.
|
||||
fn openPath(path: []const u8) ?ipc.Handle {
|
||||
const registry = reach(path) orelse return null;
|
||||
const answered = transact(.open, registry.handle, .{ .flags = 0 }, registry.path(), null) orelse return null;
|
||||
if (answered.status.status != 0) {
|
||||
// A refusal carries no channel; anything that arrived anyway would be a
|
||||
// handle-table slot spent for nothing.
|
||||
if (answered.capability) |handle| _ = ipc.close(handle);
|
||||
return null;
|
||||
}
|
||||
// The capability *is* the channel — an open that succeeds without one was
|
||||
// answered by a file backend, which does not speak protocols.
|
||||
return answered.capability;
|
||||
}
|
||||
|
||||
/// The provider's raw endpoint behind `/protocol/<name>`. The transitional form,
|
||||
/// for the clients that still marshal their protocol's bytes by hand; P4 moves
|
||||
/// them onto `Channel` proper and this shrinks back to `connect`.
|
||||
///
|
||||
/// Null covers both "no such contract" and "you may not have it" — deliberately
|
||||
/// the same answer (protocol-namespace.md: enforcement is absence), and also
|
||||
/// "the registry is not mounted yet", which is why every caller retries.
|
||||
pub fn openEndpoint(name: []const u8) ?ipc.Handle {
|
||||
var path: [path_maximum]u8 = undefined;
|
||||
const full = join(name, &path) orelse return null;
|
||||
return openPath(full);
|
||||
}
|
||||
|
||||
/// Claim `/protocol/<name>` for `endpoint`: the registry records the name
|
||||
/// against this process and hands the endpoint to whoever opens it afterwards.
|
||||
/// The endpoint rides the call as its capability, the one direction-crossing
|
||||
/// move kernel-ipc offers.
|
||||
///
|
||||
/// Three-valued on purpose. **Null** is "the registry could not be reached" —
|
||||
/// it is not mounted yet, which happens when a provider starts before init has
|
||||
/// finished coming up, and the answer is to retry. A **value** is the registry's
|
||||
/// verdict and is final: 0 bound, `-EPERM` this binary is not granted that name,
|
||||
/// `-EBUSY` a live provider already holds it.
|
||||
pub fn bind(name: []const u8, endpoint: ipc.Handle) ?i32 {
|
||||
const registry = reach(root) orelse return null;
|
||||
const answered = transact(.bind, registry.handle, {}, name, endpoint) orelse return null;
|
||||
return answered.status.status;
|
||||
}
|
||||
|
||||
/// How long a provider keeps offering itself before giving up. The registry is
|
||||
/// init, which mounts `/protocol` before it spawns anyone, so in a normal boot
|
||||
/// the first try lands; a provider the kernel test harness starts may well beat
|
||||
/// init to the mount, which is what the patience is for. Four seconds of 20 ms
|
||||
/// tries — the same cadence every client in the tree spends finding a service.
|
||||
const bind_attempts: u32 = 200;
|
||||
const bind_retry_ms: u64 = 20;
|
||||
|
||||
/// `bind`, waiting out a registry that is not mounted yet. Only unreachability
|
||||
/// is retried: a registry that *answered* has decided, and asking again cannot
|
||||
/// change its mind. True when the name is ours.
|
||||
pub fn bindPatiently(name: []const u8, endpoint: ipc.Handle) bool {
|
||||
var attempt: u32 = 0;
|
||||
while (attempt < bind_attempts) : (attempt += 1) {
|
||||
if (bind(name, endpoint)) |status| return status == 0;
|
||||
time.sleepMillis(bind_retry_ms);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// `/protocol/` + `name`, in the caller's buffer. Null if the name is empty or
|
||||
/// longer than the namespace admits.
|
||||
fn join(name: []const u8, buffer: []u8) ?[]u8 {
|
||||
if (name.len == 0 or name.len > name_maximum) return null;
|
||||
const total = root.len + 1 + name.len;
|
||||
if (total > buffer.len) return null;
|
||||
@memcpy(buffer[0..root.len], root);
|
||||
buffer[root.len] = '/';
|
||||
@memcpy(buffer[root.len + 1 ..][0..name.len], name);
|
||||
return buffer[0..total];
|
||||
}
|
||||
|
||||
/// 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 "a contract name joins the namespace root exactly once" {
|
||||
var buffer: [path_maximum]u8 = undefined;
|
||||
try testing.expectEqualStrings("/protocol/display", join("display", &buffer).?);
|
||||
try testing.expectEqualStrings("/protocol/test/shared-memory", join("test/shared-memory", &buffer).?);
|
||||
try testing.expect(join("", &buffer) == null);
|
||||
try testing.expect(join("x" ** (name_maximum + 1), &buffer) == null);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -14,8 +14,13 @@ const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
const sc = @import("system-call");
|
||||
const ipc = @import("ipc");
|
||||
const envelope = @import("envelope");
|
||||
const vfs_protocol = @import("vfs-protocol");
|
||||
|
||||
/// The generated vfs contract: encode/decode for every verb, with the node id
|
||||
/// carried in the packet header's `target`.
|
||||
const Protocol = vfs_protocol.Protocol;
|
||||
|
||||
/// The kind of a filesystem node — re-exported so a caller need not import the
|
||||
/// wire protocol.
|
||||
pub const Kind = vfs_protocol.NodeKind;
|
||||
@@ -39,6 +44,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,
|
||||
};
|
||||
}
|
||||
@@ -88,23 +94,24 @@ fn resolve(path: []const u8, flags: usize) ?Route {
|
||||
}
|
||||
}
|
||||
|
||||
const Result = struct { reply: vfs_protocol.Reply, payload: []u8 };
|
||||
|
||||
// One request/reply round trip: [Request header][send payload] -> backend ->
|
||||
// [Reply header][receive payload]. The receive payload lands in `out`.
|
||||
fn transact(h: ipc.Handle, request: vfs_protocol.Request, send: []const u8, out: []u8) ?Result {
|
||||
var message: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
@memcpy(message[0..vfs_protocol.request_size], std.mem.asBytes(&request));
|
||||
const slen = @min(send.len, vfs_protocol.maximum_payload);
|
||||
@memcpy(message[vfs_protocol.request_size..][0..slen], send[0..slen]);
|
||||
|
||||
var rbuf: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const n = ipc.call(h, message[0 .. vfs_protocol.request_size + slen], &rbuf) catch return null;
|
||||
if (n < vfs_protocol.reply_size) return null;
|
||||
const reply = std.mem.bytesToValue(vfs_protocol.Reply, rbuf[0..vfs_protocol.reply_size]);
|
||||
const rpl = @min(n - vfs_protocol.reply_size, out.len);
|
||||
@memcpy(out[0..rpl], rbuf[vfs_protocol.reply_size..][0..rpl]);
|
||||
return .{ .reply = reply, .payload = out[0..rpl] };
|
||||
// One request/reply round trip: frame `[Header][request][tail]`, send it, and
|
||||
// hand back the whole reply packet for the caller to decode with the generated
|
||||
// helpers. A backend that refused (a negative status) reads as null, which is
|
||||
// what every caller here did with it anyway.
|
||||
fn transact(
|
||||
comptime operation: Protocol.Operation,
|
||||
handle: ipc.Handle,
|
||||
target: u64,
|
||||
request: Protocol.RequestOf(operation),
|
||||
tail: []const u8,
|
||||
reply: []u8,
|
||||
) ?[]u8 {
|
||||
var packet: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeRequest(operation, target, request, tail, &packet) orelse return null;
|
||||
const n = ipc.call(handle, framed, reply) catch return null;
|
||||
const status = envelope.statusOf(reply[0..n]) orelse return null;
|
||||
if (status.status != 0) return null;
|
||||
return reply[0..n];
|
||||
}
|
||||
|
||||
/// An open file: a VFS node plus a byte cursor. Read and write advance the cursor.
|
||||
@@ -124,11 +131,13 @@ pub const File = struct {
|
||||
return n;
|
||||
};
|
||||
const want: u32 = @intCast(@min(buffer.len, vfs_protocol.maximum_payload));
|
||||
const request = vfs_protocol.Request{ .operation = .read, .node = self.node, .offset = self.offset, .len = want, .flags = 0 };
|
||||
const r = transact(h, request, &.{}, buffer) orelse return null;
|
||||
if (r.reply.status != 0) return null;
|
||||
self.offset += r.reply.len;
|
||||
return r.reply.len;
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answered = transact(.read, h, self.node, .{ .offset = self.offset, .len = want }, &.{}, &reply) orelse return null;
|
||||
const bytes = Protocol.replyTail(.read, answered);
|
||||
const n = @min(bytes.len, buffer.len);
|
||||
@memcpy(buffer[0..n], bytes[0..n]);
|
||||
self.offset += n;
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Write `data` at the current offset; returns the count written. A single
|
||||
@@ -138,11 +147,11 @@ pub const File = struct {
|
||||
pub fn write(self: *File, data: []const u8) ?usize {
|
||||
const h = self.backend orelse return null;
|
||||
const want: u32 = @intCast(@min(data.len, vfs_protocol.maximum_payload));
|
||||
const request = vfs_protocol.Request{ .operation = .write, .node = self.node, .offset = self.offset, .len = want, .flags = 0 };
|
||||
const r = transact(h, request, data[0..want], &.{}) orelse return null;
|
||||
if (r.reply.status != 0) return null;
|
||||
self.offset += r.reply.len;
|
||||
return r.reply.len;
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answered = transact(.write, h, self.node, .{ .offset = self.offset, .len = want }, data[0..want], &reply) orelse return null;
|
||||
const written = Protocol.decodeReply(.write, answered) orelse return null;
|
||||
self.offset += written.count;
|
||||
return written.count;
|
||||
}
|
||||
|
||||
/// Write all of `data`, looping past the per-call payload cap. Returns the
|
||||
@@ -168,11 +177,9 @@ pub const File = struct {
|
||||
const a = fsNodeStatus(self.node) orelse return null;
|
||||
return .{ .size = a.size, .kind = if (a.kind == file_kind_directory) .directory else .regular, .mtime = a.mtime };
|
||||
};
|
||||
const request = vfs_protocol.Request{ .operation = .status, .node = self.node, .offset = 0, .len = 0, .flags = 0 };
|
||||
var buffer: [@sizeOf(vfs_protocol.FileStatus)]u8 = undefined;
|
||||
const r = transact(h, request, &.{}, &buffer) orelse return null;
|
||||
if (r.reply.status != 0 or r.payload.len < @sizeOf(vfs_protocol.FileStatus)) return null;
|
||||
const status = std.mem.bytesToValue(vfs_protocol.FileStatus, buffer[0..@sizeOf(vfs_protocol.FileStatus)]);
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answered = transact(.status, h, self.node, {}, &.{}, &reply) orelse return null;
|
||||
const status = Protocol.decodeReply(.status, answered) orelse return null;
|
||||
return .{ .size = status.size, .kind = kindFromWire(status.kind), .mtime = status.mtime };
|
||||
}
|
||||
|
||||
@@ -180,8 +187,8 @@ pub const File = struct {
|
||||
/// tokens are permanent — nothing to release.
|
||||
pub fn close(self: *File) void {
|
||||
const h = self.backend orelse return;
|
||||
const request = vfs_protocol.Request{ .operation = .close, .node = self.node, .offset = 0, .len = 0, .flags = 0 };
|
||||
_ = transact(h, request, &.{}, &.{});
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
_ = transact(.close, h, self.node, {}, &.{}, &reply);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -192,10 +199,10 @@ pub fn open(path: []const u8, options: OpenOptions) ?File {
|
||||
.kernel => |token| return .{ .node = token, .backend = null },
|
||||
.backend => |b| {
|
||||
const relative = route.backendPath();
|
||||
const request = vfs_protocol.Request{ .operation = .open, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = options.wireFlags() };
|
||||
const r = transact(b.handle, request, relative, &.{}) orelse return null;
|
||||
if (r.reply.status != 0) return null;
|
||||
return .{ .node = r.reply.node, .backend = b.handle };
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answered = transact(.open, b.handle, 0, .{ .flags = options.wireFlags() }, relative, &reply) orelse return null;
|
||||
const opened = Protocol.decodeReply(.open, answered) orelse return null;
|
||||
return .{ .node = opened.node, .backend = b.handle };
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -247,15 +254,13 @@ pub const Directory = struct {
|
||||
self.cursor += 1;
|
||||
return true;
|
||||
};
|
||||
const request = vfs_protocol.Request{ .operation = .readdir, .node = self.node, .offset = self.cursor, .len = 0, .flags = 0 };
|
||||
var buffer: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const r = transact(h, request, &.{}, &buffer) orelse return false;
|
||||
if (r.reply.status != 0 or r.reply.len == 0) return false; // error or EOF
|
||||
if (r.payload.len < vfs_protocol.directory_entry_size) return false;
|
||||
const header = std.mem.bytesToValue(vfs_protocol.DirectoryEntry, r.payload[0..vfs_protocol.directory_entry_size]);
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
const answered = transact(.readdir, h, self.node, .{ .cursor = self.cursor }, &.{}, &reply) orelse return false;
|
||||
const header = Protocol.decodeReply(.readdir, answered) orelse return false;
|
||||
if (header.name_len == 0) return false; // end of directory
|
||||
entry.kind = kindFromWire(header.kind);
|
||||
entry.size = header.size;
|
||||
const source = r.payload[vfs_protocol.directory_entry_size..];
|
||||
const source = Protocol.replyTail(.readdir, answered);
|
||||
const nlen = @min(@min(@as(usize, header.name_len), source.len), entry.name_buffer.len);
|
||||
@memcpy(entry.name_buffer[0..nlen], source[0..nlen]);
|
||||
entry.name_len = nlen;
|
||||
@@ -279,13 +284,11 @@ pub fn openDirectory(path: []const u8) ?Directory {
|
||||
// A path-based request that returns only a status (mkdir, unlink). Kernel-served
|
||||
// paths (the read-only /system) refuse mutation by construction: the resolve
|
||||
// must land on a backend.
|
||||
fn pathOperation(operation: vfs_protocol.Operation, path: []const u8) bool {
|
||||
fn pathOperation(comptime operation: Protocol.Operation, path: []const u8) bool {
|
||||
const route = resolve(path, 0) orelse return false;
|
||||
if (route != .backend) return false;
|
||||
const relative = route.backendPath();
|
||||
const request = vfs_protocol.Request{ .operation = operation, .node = 0, .offset = 0, .len = @intCast(relative.len), .flags = 0 };
|
||||
const r = transact(route.backend.handle, request, relative, &.{}) orelse return false;
|
||||
return r.reply.status == 0;
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
return transact(operation, route.backend.handle, 0, {}, route.backendPath(), &reply) != null;
|
||||
}
|
||||
|
||||
/// Create a directory at `path` (its parent must already exist). Returns true on
|
||||
@@ -305,7 +308,7 @@ pub fn makePath(path: []const u8) bool {
|
||||
while (end < path.len and path[end] != '/') end += 1;
|
||||
const prefix = path[0..end];
|
||||
if (prefix.len == 0 or (prefix.len == 1 and prefix[0] == '/')) continue;
|
||||
// Best-effort per prefix: components at or above a mount point ("/mnt")
|
||||
// Best-effort per prefix: components at or above a mount point ("/volumes")
|
||||
// are router names, not filesystem nodes — they neither exist as nodes
|
||||
// nor accept mkdir, and that is fine. Only the final verdict counts.
|
||||
if (!exists(prefix)) _ = makeDirectory(prefix);
|
||||
@@ -335,9 +338,8 @@ pub fn rename(old_path: []const u8, new_path: []const u8) bool {
|
||||
@memcpy(payload[0..old_relative.len], old_relative);
|
||||
payload[old_relative.len] = 0;
|
||||
@memcpy(payload[old_relative.len + 1 ..][0..new_relative.len], new_relative);
|
||||
const request = vfs_protocol.Request{ .operation = .rename, .node = 0, .offset = 0, .len = @intCast(total), .flags = 0 };
|
||||
const r = transact(old_route.backend.handle, request, payload[0..total], &.{}) orelse return false;
|
||||
return r.reply.status == 0;
|
||||
var reply: [vfs_protocol.message_maximum]u8 = undefined;
|
||||
return transact(.rename, old_route.backend.handle, 0, {}, payload[0..total], &reply) != null;
|
||||
}
|
||||
|
||||
/// Mount a filesystem backend (its server endpoint) at absolute path `target`;
|
||||
@@ -348,8 +350,9 @@ pub fn mount(target: []const u8, backend: ipc.Handle) bool {
|
||||
}
|
||||
|
||||
/// As `mount`, with a backend-side rewrite prefix: a path under `target` reaches
|
||||
/// the backend as `rewrite` + the mount-relative tail. How one volume serves two
|
||||
/// mounts ("/mnt/usb" from its root, "/var" from its /var subtree).
|
||||
/// the backend as `rewrite` + the mount-relative tail. How one volume serves
|
||||
/// several mounts ("/volumes/usb" from its root, "/system/logs" from its
|
||||
/// /system/logs subtree).
|
||||
pub fn mountRewritten(target: []const u8, backend: ipc.Handle, rewrite: []const u8) bool {
|
||||
return fsMount(target, backend, rewrite);
|
||||
}
|
||||
|
||||
+54
-11
@@ -29,10 +29,10 @@ pub fn createIpcEndpoint() ?Handle {
|
||||
return if (failed(r)) null else r;
|
||||
}
|
||||
|
||||
/// Publish endpoint `h` under a well-known service id so other processes find it.
|
||||
pub fn register(id: abi.ServiceId, h: Handle) bool {
|
||||
return !failed(sc.systemCall2(.ipc_register, @intFromEnum(id), h));
|
||||
}
|
||||
// `register`/`lookup` lived here — the two wrappers over the flat ServiceId
|
||||
// registry. Naming is not a system call any more: a provider binds its contract
|
||||
// name at the registry and a client resolves and opens `/protocol/<name>`, both
|
||||
// through `channel` (docs/os-development/protocol-namespace.md).
|
||||
|
||||
/// Drop a capability handle (endpoint, shared-memory, or DMA-region) and free its table
|
||||
/// slot. A forwarding hop closes a cap it passed on; a binder closes a DMA-region cap
|
||||
@@ -42,13 +42,6 @@ pub fn close(h: Handle) bool {
|
||||
return !failed(sc.systemCall1(.handle_close, h));
|
||||
}
|
||||
|
||||
/// Find the endpoint published under `id`, installing a handle to it in this
|
||||
/// process.
|
||||
pub fn lookup(id: abi.ServiceId) ?Handle {
|
||||
const r = sc.systemCall1(.ipc_lookup, @intFromEnum(id));
|
||||
return if (failed(r)) null else r;
|
||||
}
|
||||
|
||||
pub const CallError = error{Failed};
|
||||
|
||||
/// The result of a capability-passing `callCap`: the reply length, and the handle of
|
||||
@@ -178,6 +171,56 @@ pub const Received = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// A capability that arrived with one turn of a receive loop, and the ownership
|
||||
/// rule for it: **the turn owns it until a handler takes it, and closes whatever
|
||||
/// is left.**
|
||||
///
|
||||
/// The kernel installs a sent capability in the receiver's handle table whenever
|
||||
/// the caller attached one, *independent of the message's length or kind*
|
||||
/// (system/kernel/ipc-synchronous.zig `replyWait`), so every path out of a loop
|
||||
/// has to dispose of one — including the paths that never look at the message.
|
||||
/// The table is thirty-two slots, and `ipc_call` does not dedupe, so a client
|
||||
/// looping on `callCap(server, &.{}, endpoint)` spends one slot per call: about
|
||||
/// thirty-two zero-length pings and the service can never accept another
|
||||
/// capability, which means no subscribe and no shared-memory handover, for the
|
||||
/// rest of the boot. It is unauthenticated and it is two lines to write.
|
||||
///
|
||||
/// So ownership is structural rather than a close per branch — the per-branch
|
||||
/// version has already failed twice in this tree, in PID 1's ping path and in
|
||||
/// every `service.run` callback that simply ignored its capability argument.
|
||||
/// Written this way, forgetting **closes**, and *keeping* a capability is the
|
||||
/// thing a handler has to say out loud:
|
||||
///
|
||||
/// ```zig
|
||||
/// var arrived: ipc.Arrival = .{ .handle = got.cap };
|
||||
/// defer arrived.release(); // every exit path, including `continue`
|
||||
/// ...
|
||||
/// const kept = arrived.take().?; // claimed: mine to hold or close
|
||||
/// ```
|
||||
pub const Arrival = struct {
|
||||
handle: ?Handle = null,
|
||||
|
||||
/// Look without claiming — a handler that may still refuse wants no close of
|
||||
/// its own on the refusal paths.
|
||||
pub fn peek(self: *const Arrival) ?Handle {
|
||||
return self.handle;
|
||||
}
|
||||
|
||||
/// Claim ownership: from here the capability is the taker's to keep or close,
|
||||
/// and the turn will not touch it.
|
||||
pub fn take(self: *Arrival) ?Handle {
|
||||
defer self.handle = null;
|
||||
return self.handle;
|
||||
}
|
||||
|
||||
/// Close whatever nobody claimed. Idempotent, so it is safe as a `defer` next
|
||||
/// to any number of `take`s.
|
||||
pub fn release(self: *Arrival) void {
|
||||
if (self.handle) |handle| _ = close(handle);
|
||||
self.handle = null;
|
||||
}
|
||||
};
|
||||
|
||||
/// Server side of IPC_ReplyWait: deliver `reply` to the client last received (if any,
|
||||
/// optionally handing it `send_cap`), then block until the next request arrives in
|
||||
/// `receive`. Returns its length, the sender badge, and any capability the request
|
||||
|
||||
@@ -142,6 +142,18 @@ pub fn subscribeExits(endpoint: usize) bool {
|
||||
/// snapshot buffer without importing `abi` itself.
|
||||
pub const ProcessDescriptor = abi.ProcessDescriptor;
|
||||
|
||||
/// The calling task's own kernel id — its row in the process table, and the value
|
||||
/// every other process sees as this one's `supervisor` after it spawns them. For a
|
||||
/// single-threaded program that is its process id; in a threaded one it is the
|
||||
/// calling thread's id (`Thread.getCurrentId` is the same system call, named for
|
||||
/// the threading vocabulary). Ids are monotonic and never reused
|
||||
/// (system/kernel/process.zig), which is what makes comparing one an identity
|
||||
/// test where comparing a *name* is only a resemblance test — the registrar in
|
||||
/// init leans on exactly that.
|
||||
pub fn taskId() u32 {
|
||||
return @intCast(sc.systemCall0(.thread_self));
|
||||
}
|
||||
|
||||
/// Give up the rest of this quantum.
|
||||
pub fn yield() void {
|
||||
_ = sc.systemCall0(.yield);
|
||||
|
||||
+288
-16
@@ -6,26 +6,64 @@
|
||||
//! loop chose, never on a hijacked stack — the whole reason signals are
|
||||
//! messages.
|
||||
//!
|
||||
//! One rule a service author does have to know, and it is stated on
|
||||
//! `Callbacks.on_message`: **a capability that arrives belongs to the turn** —
|
||||
//! the loop closes it unless the callback claims it with `take()`. Forgetting is
|
||||
//! therefore safe, and keeping is explicit; the opposite arrangement quietly
|
||||
//! spends a handle-table slot per request.
|
||||
//!
|
||||
//! The harness also owns the **subscriber side** of a protocol that declares
|
||||
//! `.events` — see `Subscribers`. The table, the reserved subscribe/unsubscribe
|
||||
//! verbs, the fan-out, and the dead-subscriber sweep live here rather than in
|
||||
//! each provider, so every event stream in the system has identical semantics
|
||||
//! (docs/os-development/protocol-namespace.md, "Wiring").
|
||||
//!
|
||||
//! The liveness probe: a **zero-length request is the universal ping**, answered
|
||||
//! with a zero-length reply by the harness itself. No protocol's requests start
|
||||
//! at length zero, so the encoding cannot collide, and there is nothing for a
|
||||
//! service author to implement — a wedged service simply fails to answer, which
|
||||
//! is the diagnosis (see docs/ipc.md).
|
||||
|
||||
const abi = @import("abi");
|
||||
const channel = @import("channel");
|
||||
const envelope = @import("envelope");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
|
||||
/// The harness's handle on a provider's subscriber table, type-erased because
|
||||
/// `run` is not generic over the protocol while `Subscribers` is. A service names
|
||||
/// its table once, as `Callbacks.subscribers`, and the loop does the rest: it
|
||||
/// subscribes to published process exits at startup and drops a dead task's
|
||||
/// subscriptions before the service's own notification callback ever sees the
|
||||
/// badge.
|
||||
pub const SubscriberHooks = struct {
|
||||
/// Ask the kernel for published exit events on this service's endpoint.
|
||||
watch: *const fn (endpoint: ipc.Handle) void,
|
||||
/// Drop everything task `dead` had subscribed.
|
||||
forget: *const fn (dead: u32) void,
|
||||
};
|
||||
|
||||
pub const Callbacks = struct {
|
||||
/// Called once with the service's endpoint before the loop starts — the
|
||||
/// place to subscribe to exit events, bind IRQs, or announce readiness.
|
||||
/// Return false to abort startup (the process exits).
|
||||
init: ?*const fn (endpoint: ipc.Handle) bool = null,
|
||||
/// One protocol request from `sender` (a task id): write the reply into
|
||||
/// `reply`, return its length. `capability` is the handle the request
|
||||
/// carried, if any (M13 cap passing — how a subscriber hands over its
|
||||
/// endpoint). The zero-length ping never reaches this.
|
||||
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize,
|
||||
/// `reply`, return its length. The zero-length ping never reaches this.
|
||||
///
|
||||
/// `arrived` is the capability the request carried (M13 cap passing — how a
|
||||
/// subscriber hands over its endpoint), and it comes with **an ownership
|
||||
/// rule: the turn owns it, and a handler that wants to keep it must say so
|
||||
/// with `take()`.** Whatever is left when this returns, the loop closes.
|
||||
/// `peek()` reads it without claiming, which is what a handler that may
|
||||
/// still refuse wants — no close of its own on the refusal paths.
|
||||
///
|
||||
/// The rule is stated here, in the contract, because the alternative has
|
||||
/// failed in practice: an implementation that simply ignored a `?ipc.Handle`
|
||||
/// argument leaked a handle table slot per request, and every operation
|
||||
/// except a subscribe ignores it. Thirty-two such requests — zero-length
|
||||
/// pings will do, and they need no authorization — and the service can never
|
||||
/// accept another capability for the rest of the boot. See `ipc.Arrival`.
|
||||
on_message: *const fn (message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize,
|
||||
/// A notification that is not a signal — a subscribed exit event, a bound
|
||||
/// IRQ, a timer landing. The raw badge; decode with the ipc helpers.
|
||||
on_notification: ?*const fn (badge: u64) void = null,
|
||||
@@ -35,21 +73,238 @@ pub const Callbacks = struct {
|
||||
/// the return itself — never put *necessary* work here (iron rule 1: a kill
|
||||
/// arrives with no warning; this is for graceful extras only).
|
||||
on_terminate: ?*const fn () void = null,
|
||||
/// Publish the endpoint under a well-known service id at startup.
|
||||
service: ?abi.ServiceId = null,
|
||||
/// The contract this service provides: a name under `/protocol`, mirroring
|
||||
/// the `library/protocol/` module that defines the wire format — a program
|
||||
/// imports `display-protocol` and the provider binds `"display"`
|
||||
/// (docs/os-development/protocol-namespace.md). Bound at startup, before
|
||||
/// `init` runs, so the service is reachable the moment it serves. A refusal
|
||||
/// (not granted, or a live provider already holds the name) aborts startup.
|
||||
service: ?[]const u8 = null,
|
||||
/// This provider's subscriber table — `Subscribers(Protocol, Context).hooks`
|
||||
/// — for a protocol that declares `.events`. Naming it here is what buys the
|
||||
/// exit-notification sweep: the loop subscribes to published deaths at
|
||||
/// startup and releases a dead subscriber's slot (and the endpoint capability
|
||||
/// in it) when one lands.
|
||||
subscribers: ?SubscriberHooks = null,
|
||||
};
|
||||
|
||||
/// Run the service: create and (optionally) register the endpoint, bind signals
|
||||
/// to it, call `init`, then serve until `terminate` arrives — at which point the
|
||||
/// loop returns and main's return is the clean exit the supervisor reads as
|
||||
/// `ExitReason.exited`. `maximum_message` sizes the receive and reply buffers
|
||||
/// (a service passes its protocol's message maximum).
|
||||
/// How many subscribers one provider fans out to. Bounded like every table in
|
||||
/// this system; a subscribe past the end is refused with `-ENOSPC` rather than
|
||||
/// silently forgetting an earlier one.
|
||||
pub const subscriber_capacity = 8;
|
||||
|
||||
/// The interest mask that means "every event of this protocol" — what a
|
||||
/// subscriber which named no class gets, and what a provider passes when the
|
||||
/// event it is publishing belongs to no class.
|
||||
pub const every_event: u32 = 0;
|
||||
|
||||
/// The subscriber side of a protocol, for a provider whose contract declares
|
||||
/// `.events` (docs/os-development/protocol-namespace.md: *the harness owns the
|
||||
/// machinery — the subscriber table, the dead-subscriber sweep, and the fan-out
|
||||
/// loop*). Three services hand-rolled this, with three different ideas of when a
|
||||
/// dead subscriber goes away — a poll of the process list on subscribe, a drop on
|
||||
/// a failed send, and nothing at all. This is the one idiom.
|
||||
///
|
||||
/// ```zig
|
||||
/// const Subscriptions = service.Subscribers(power_protocol.Protocol, void);
|
||||
/// ...
|
||||
/// fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
/// return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply);
|
||||
/// }
|
||||
/// pub fn main() void {
|
||||
/// service.run(power_protocol.message_maximum, .{
|
||||
/// .service = "power",
|
||||
/// .on_message = onMessage,
|
||||
/// .subscribers = Subscriptions.hooks,
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// What the provider still writes is its own events — `publish(.power_button, 0,
|
||||
/// .{})`. Everything else happens here: registering the caller's endpoint on the
|
||||
/// reserved `subscribe` verb, taking that capability out of the turn, dropping it
|
||||
/// on `unsubscribe` or on the subscriber's death, and framing one packet for the
|
||||
/// whole fan-out.
|
||||
///
|
||||
/// The table is per instantiation (a container-level `var` inside the generic
|
||||
/// type), so a process providing two contracts gets two tables and neither can
|
||||
/// see the other's subscribers.
|
||||
pub fn Subscribers(comptime Protocol: type, comptime Context: type) type {
|
||||
return struct {
|
||||
/// The generated dispatch this provider answers with.
|
||||
pub const Provider = Protocol.Provider(Context);
|
||||
pub const Handlers = Provider.Handlers;
|
||||
|
||||
/// One registered subscriber: the endpoint events are pushed to (the
|
||||
/// capability it handed over at subscribe time, which this slot owns),
|
||||
/// the task that handed it over — the kernel-stamped badge, the only
|
||||
/// source identity there is — and which classes of event it asked for.
|
||||
const Slot = struct {
|
||||
used: bool = false,
|
||||
endpoint: ipc.Handle = 0,
|
||||
task: u32 = 0,
|
||||
interest: u32 = every_event,
|
||||
};
|
||||
|
||||
var slots: [subscriber_capacity]Slot = .{Slot{}} ** subscriber_capacity;
|
||||
|
||||
/// Set when a slot has taken the capability the turn carried, and read
|
||||
/// back in `dispatch`, 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 claimed = false;
|
||||
|
||||
/// What `Callbacks.subscribers` is given.
|
||||
pub const hooks: SubscriberHooks = .{ .watch = watchExits, .forget = forget };
|
||||
|
||||
fn watchExits(endpoint: ipc.Handle) void {
|
||||
// Published exits, not a poll of the process list: a service must
|
||||
// never depend on clients cleaning up after themselves, and it must
|
||||
// not have to walk the whole table on every subscribe to find out
|
||||
// either (docs/process-lifecycle.md, "Who learns of a death").
|
||||
_ = process.subscribeExits(endpoint);
|
||||
}
|
||||
|
||||
/// Release everything task `dead` had subscribed. The slot owns the
|
||||
/// endpoint capability, so reclaiming the slot closes it — otherwise a
|
||||
/// process that subscribes and dies costs a handle-table slot that never
|
||||
/// comes back.
|
||||
pub fn forget(dead: u32) void {
|
||||
for (&slots) |*slot| {
|
||||
if (slot.used and slot.task == dead) {
|
||||
_ = ipc.close(slot.endpoint);
|
||||
slot.* = .{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `task` is a subscriber — the gate for an operation a provider
|
||||
/// honours from its subscribers and nobody else. The power service's
|
||||
/// shutdown is the one: the badge is kernel-stamped, so nothing in a
|
||||
/// packet can claim to be the subscriber that already ran the stop
|
||||
/// sequence.
|
||||
pub fn has(task: u32) bool {
|
||||
for (&slots) |*slot| {
|
||||
if (slot.used and slot.task == task) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Answer one received packet, with the reserved `subscribe` and
|
||||
/// `unsubscribe` verbs already wired — a provider that leaves those two
|
||||
/// handlers null (every provider should) gets the harness's. The turn's
|
||||
/// capability is peeked, never taken, unless a slot actually kept it.
|
||||
pub fn dispatch(
|
||||
context: Context,
|
||||
handlers: Handlers,
|
||||
packet: []const u8,
|
||||
sender: u32,
|
||||
arrived: *ipc.Arrival,
|
||||
reply: []u8,
|
||||
) usize {
|
||||
var wired = handlers;
|
||||
if (wired.subscribe == null) wired.subscribe = onSubscribe;
|
||||
if (wired.unsubscribe == null) wired.unsubscribe = onUnsubscribe;
|
||||
claimed = false;
|
||||
const written = Provider.dispatch(context, wired, packet, sender, arrived.peek(), reply);
|
||||
if (claimed) _ = arrived.take();
|
||||
return written;
|
||||
}
|
||||
|
||||
/// Push one event to every subscriber.
|
||||
pub fn publish(
|
||||
comptime event: Protocol.Event,
|
||||
target: u64,
|
||||
payload: Protocol.PayloadOf(event),
|
||||
) void {
|
||||
publishClass(event, target, payload, every_event);
|
||||
}
|
||||
|
||||
/// Push one event to the subscribers whose interest mask includes
|
||||
/// `class` (a subscriber that named no class takes everything). The
|
||||
/// packet is framed **once**, outside the loop, so every subscriber of a
|
||||
/// class receives identical bytes; and delivery is `ipc.send`, which
|
||||
/// never blocks, so one slow or dead subscriber can never stall the rest
|
||||
/// — the whole reason broadcast is a provider pattern and not a kernel
|
||||
/// primitive.
|
||||
pub fn publishClass(
|
||||
comptime event: Protocol.Event,
|
||||
target: u64,
|
||||
payload: Protocol.PayloadOf(event),
|
||||
class: u32,
|
||||
) void {
|
||||
var packet: [envelope.post_maximum]u8 = undefined;
|
||||
const framed = Protocol.encodeEvent(event, target, payload, &packet) orelse return;
|
||||
for (&slots) |*slot| {
|
||||
if (!slot.used) continue;
|
||||
if (!wants(slot.*, class)) continue;
|
||||
// The sweep is what normally reclaims a dead subscriber, promptly
|
||||
// and with its capability closed. This is the backstop for a
|
||||
// notification that never arrived: an endpoint's notify ring is
|
||||
// bounded, so a burst of deaths can drop one, and a send to an
|
||||
// endpoint whose owner is gone fails rather than blocking.
|
||||
if (!ipc.send(slot.endpoint, framed)) {
|
||||
_ = ipc.close(slot.endpoint);
|
||||
slot.* = .{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wants(slot: Slot, class: u32) bool {
|
||||
if (class == every_event) return true; // the event belongs to no class
|
||||
if (slot.interest == every_event) return true; // the subscriber named none
|
||||
return slot.interest & class != 0;
|
||||
}
|
||||
|
||||
/// The reserved `subscribe` verb: register the caller's endpoint (the
|
||||
/// call's capability) for the classes its tail names. A refusal simply
|
||||
/// returns and the turn closes what arrived — the harness's ownership
|
||||
/// rule (`ipc.Arrival`), which is why a subscribe storm against a full
|
||||
/// table cannot spend the handle table.
|
||||
fn onSubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
|
||||
const endpoint = invocation.capability orelse return -envelope.EPROTO; // no endpoint passed
|
||||
const interest = envelope.decodeSubscribe(invocation.tail).interest;
|
||||
for (&slots) |*slot| {
|
||||
if (slot.used) continue;
|
||||
// Appended, not replaced: one task may hold several subscriptions
|
||||
// on different endpoints (a client taking keyboard and mouse as
|
||||
// two streams), and each is its own conversation.
|
||||
slot.* = .{ .used = true, .endpoint = endpoint, .task = invocation.sender, .interest = interest };
|
||||
claimed = true; // the table holds it until that task dies
|
||||
return 0;
|
||||
}
|
||||
return -envelope.ENOSPC; // table full
|
||||
}
|
||||
|
||||
/// The reserved `unsubscribe` verb: every subscription the calling task
|
||||
/// holds here goes, which is exactly what its death would do. It names no
|
||||
/// endpoint because the badge already names the only subscriber a caller
|
||||
/// can speak for — its own.
|
||||
fn onUnsubscribe(_: Context, invocation: envelope.Invocation(void), _: envelope.Answer(void)) isize {
|
||||
if (!has(invocation.sender)) return -envelope.ENOENT;
|
||||
forget(invocation.sender);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// Run the service: create the endpoint, bind it under the service's contract
|
||||
/// name (if it has one), bind signals to it, call `init`, then serve until
|
||||
/// `terminate` arrives — at which point the loop returns and main's return is
|
||||
/// the clean exit the supervisor reads as `ExitReason.exited`.
|
||||
/// `maximum_message` sizes the receive and reply buffers (a service passes its
|
||||
/// protocol's message maximum).
|
||||
pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
const endpoint = ipc.createIpcEndpoint() orelse return;
|
||||
if (callbacks.service) |id| {
|
||||
if (!ipc.register(id, endpoint)) return;
|
||||
if (callbacks.service) |name| {
|
||||
if (!channel.bindPatiently(name, endpoint)) return;
|
||||
}
|
||||
_ = process.bindSignals(endpoint);
|
||||
// Before `init`, so a subscriber that arrives the instant the name is bound
|
||||
// is already covered by the sweep that will release it.
|
||||
if (callbacks.subscribers) |subscribers| subscribers.watch(endpoint);
|
||||
if (callbacks.init) |initialise| {
|
||||
if (!initialise(endpoint)) return;
|
||||
}
|
||||
@@ -59,6 +314,16 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
var receive: [maximum_message]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 a callback claims it (`ipc.Arrival`). Structural
|
||||
// rather than a close per branch, because the branches are exactly what
|
||||
// gets forgotten: the ping's `continue` below, and every `on_message`
|
||||
// that has no use for a capability — which is every operation but a
|
||||
// subscribe. A `defer` in a loop body runs on `continue` and on the
|
||||
// `return` that ends the loop, so this covers all four exits.
|
||||
var arrived: ipc.Arrival = .{ .handle = got.cap };
|
||||
defer arrived.release();
|
||||
|
||||
if (got.isNotification()) {
|
||||
reply_len = 0; // nothing owed for a notification
|
||||
if (process.signalsFrom(got.badge)) |signals| {
|
||||
@@ -71,13 +336,20 @@ pub fn run(comptime maximum_message: usize, callbacks: Callbacks) void {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A death sweeps the subscriber table first, then still reaches the
|
||||
// service: a provider often has its own per-client state to release
|
||||
// (open file handles, device tokens, layers) and the same badge is
|
||||
// the notice for both.
|
||||
if (got.isChildExit()) {
|
||||
if (callbacks.subscribers) |subscribers| subscribers.forget(got.childProcessId());
|
||||
}
|
||||
if (callbacks.on_notification) |onNotification| onNotification(got.badge);
|
||||
continue;
|
||||
}
|
||||
if (got.len == 0) {
|
||||
reply_len = 0; // the universal ping: a zero-length reply, from the harness
|
||||
continue;
|
||||
continue; // any capability it carried goes out through the turn's `defer`
|
||||
}
|
||||
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), got.cap);
|
||||
reply_len = callbacks.on_message(receive[0..got.len], &reply_buffer, got.senderTaskId(), &arrived);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,51 +1,61 @@
|
||||
//! The block-device wire protocol — what a filesystem (the FAT server) says to a
|
||||
//! block driver (usb-storage) over its well-known `.block` endpoint. A protocol
|
||||
//! module like vfs-protocol / usb-transfer-protocol: extern-struct messages, an
|
||||
//! `Operation` tag, everything in one IPC message.
|
||||
//! block driver (usb-storage) over `/protocol/block`. Defined through the
|
||||
//! envelope, so every packet begins with the folded `Header`.
|
||||
//!
|
||||
//! **`Header.target` is always 0 here**: a block driver instance serves exactly
|
||||
//! one device over its own endpoint, so there is no object within the peer to
|
||||
//! address. A driver that later fronts several volumes gives them target ids and
|
||||
//! `enumerate` lists them; nothing else about the protocol changes.
|
||||
//!
|
||||
//! Data path: read and write move whole blocks to or from a **caller-owned DMA
|
||||
//! buffer**, named by its physical address — the same physical-address handoff
|
||||
//! usb-storage already uses toward the controller, one layer up. So a 512-byte
|
||||
//! sector never has to cross the 256-byte IPC boundary; only the small request /
|
||||
//! reply headers do. Under an enforcing IOMMU the buffer's physical addresses are
|
||||
//! only reachable by the device once the filesystem has `attach`ed the buffer's
|
||||
//! capability (the block server forwards it to the controller); see docs/driver-model.md.
|
||||
//! sector never has to cross the packet floor; only the small request / reply
|
||||
//! parts do. Under an enforcing IOMMU the buffer's physical addresses are only
|
||||
//! reachable by the device once the filesystem has `attach`ed the buffer's
|
||||
//! capability (the block server forwards it to the controller); see
|
||||
//! docs/driver-model.md.
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
/// geometry() -> { block_size, block_count }
|
||||
geometry = 0,
|
||||
/// read(lba, count, physical): read `count` blocks from `lba` into the buffer
|
||||
read = 1,
|
||||
/// write(lba, count, physical): write `count` blocks at `lba` from the buffer
|
||||
write = 2,
|
||||
/// flush(): commit any device write cache to stable media (no data transfer).
|
||||
/// A filesystem calls this to make prior writes durable — e.g. before power-off,
|
||||
/// so a shutdown-time write isn't lost in the USB flash controller's cache.
|
||||
flush = 3,
|
||||
/// attach(): the caller's DMA-region capability rides the call's cap slot; the
|
||||
/// block server forwards it to the controller so the buffer's physical addresses
|
||||
/// (named in later read/write) are reachable by the device under an enforcing
|
||||
/// IOMMU. Call once per buffer before using it in a transfer.
|
||||
attach = 4,
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// The answer to `geometry()`.
|
||||
pub const Geometry = extern struct {
|
||||
block_size: u32, // bytes per block (512)
|
||||
_padding: u32 = 0,
|
||||
block_count: u64, // total blocks
|
||||
};
|
||||
|
||||
pub const Request = extern struct {
|
||||
operation: u32,
|
||||
reserved: u32 = 0,
|
||||
/// `read(lba, count, physical)` / `write(...)`: move `count` blocks between the
|
||||
/// device and the caller's DMA buffer at `physical`.
|
||||
pub const Transfer = extern struct {
|
||||
lba: u64,
|
||||
count: u32, // number of blocks (read/write)
|
||||
reserved2: u32 = 0,
|
||||
physical: u64, // caller's DMA buffer physical address (read/write)
|
||||
count: u32,
|
||||
_padding: u32 = 0,
|
||||
physical: u64, // caller's DMA buffer physical address
|
||||
};
|
||||
|
||||
pub const Reply = extern struct {
|
||||
status: i32, // 0 on success, negative on failure
|
||||
reserved: u32 = 0,
|
||||
block_size: u32, // geometry: bytes per block (512)
|
||||
reserved2: u32 = 0,
|
||||
block_count: u64, // geometry: total blocks; read/write: blocks moved
|
||||
};
|
||||
/// How many blocks a transfer actually moved.
|
||||
pub const Transferred = extern struct { count: u32 };
|
||||
|
||||
pub const message_maximum: usize = 256;
|
||||
pub const request_size: usize = @sizeOf(Request);
|
||||
pub const reply_size: usize = @sizeOf(Reply);
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "block",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "geometry", .reply = Geometry },
|
||||
.{ .name = "read", .request = Transfer, .reply = Transferred },
|
||||
.{ .name = "write", .request = Transfer, .reply = Transferred },
|
||||
// flush(): commit any device write cache to stable media (no data
|
||||
// transfer). A filesystem calls this to make prior writes durable —
|
||||
// before power-off, so a shutdown-time write isn't lost in the USB flash
|
||||
// controller's cache.
|
||||
.{ .name = "flush" },
|
||||
// attach(): the caller's DMA-region capability rides the call's cap
|
||||
// slot; the block server forwards it to the controller so the buffer's
|
||||
// physical addresses (named in later read/write) are reachable by the
|
||||
// device under an enforcing IOMMU. Call once per buffer before using it.
|
||||
.{ .name = "attach" },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
//! 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,
|
||||
//! and therefore the one module every other one imports:
|
||||
//!
|
||||
//! 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)
|
||||
@@ -15,6 +20,11 @@
|
||||
const std = @import("std");
|
||||
|
||||
pub fn build(b: *std.Build) void {
|
||||
// Not a protocol, hence not `-protocol`: the envelope is what a protocol is
|
||||
// defined *through*, so it is built first and handed to every protocol
|
||||
// below as their one import.
|
||||
const envelope = b.addModule("envelope", .{ .root_source_file = b.path("envelope/envelope.zig") });
|
||||
|
||||
for ([_]struct { name: []const u8, root: []const u8 }{
|
||||
.{ .name = "vfs-protocol", .root = "vfs/vfs-protocol.zig" },
|
||||
.{ .name = "input-protocol", .root = "input/input-protocol.zig" },
|
||||
@@ -25,20 +35,38 @@ pub fn build(b: *std.Build) void {
|
||||
.{ .name = "scanout-protocol", .root = "scanout/scanout-protocol.zig" },
|
||||
.{ .name = "power-protocol", .root = "power/power-protocol.zig" },
|
||||
}) |protocol| {
|
||||
_ = b.addModule(protocol.name, .{ .root_source_file = b.path(protocol.root) });
|
||||
_ = b.addModule(protocol.name, .{
|
||||
.root_source_file = b.path(protocol.root),
|
||||
.imports = &.{.{ .name = "envelope", .module = envelope }},
|
||||
});
|
||||
}
|
||||
|
||||
// Standalone `zig build test` for this domain alone; the root build keeps
|
||||
// its aggregate test step.
|
||||
const test_step = b.step("test", "Run the protocol unit tests");
|
||||
// The envelope tests itself with no import of its own — everything else
|
||||
// imports it, so it is built separately rather than importing itself.
|
||||
const envelope_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path("envelope/envelope.zig"), // framing round trips, verb numbering, dispatch, the floors
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(envelope_tests).step);
|
||||
|
||||
for ([_][]const u8{
|
||||
"vfs/vfs-protocol.zig", // NodeKind / DirectoryEntry sizes + op values
|
||||
"input/input-protocol.zig", // event numbering + the push-floor budget
|
||||
"display/display-protocol.zig", // pack(): native pixel encoding per format
|
||||
"device-manager/device-manager-protocol.zig", // the dual-use report, exactly on the push floor
|
||||
"power/power-protocol.zig", // the event kind as the packet's verb
|
||||
"usb-transfer/usb-transfer-protocol.zig", // the tail-carried control stage + the trimmed report
|
||||
}) |root| {
|
||||
const protocol_tests = b.addTest(.{
|
||||
.root_module = b.createModule(.{
|
||||
.root_source_file = b.path(root),
|
||||
.target = b.resolveTargetQuery(.{}),
|
||||
.imports = &.{.{ .name = "envelope", .module = envelope }},
|
||||
}),
|
||||
});
|
||||
test_step.dependOn(&b.addRunArtifact(protocol_tests).step);
|
||||
|
||||
@@ -1,18 +1,51 @@
|
||||
//! The device-manager protocol (docs/device-manager.md): what drivers and
|
||||
//! applications say to the device manager over its well-known endpoint. The
|
||||
//! vfs-protocol pattern — extern-struct messages, a version in the handshake,
|
||||
//! reserved fields — so both sides depend on the contract by name. Deliberately
|
||||
//! contains nothing lifecycle-shaped: stopping, liveness (the zero-length ping),
|
||||
//! and exit reasons are the universal vocabulary of
|
||||
//! The device-manager protocol (docs/device-driver-development/device-manager.md):
|
||||
//! what drivers and applications say to the device manager over
|
||||
//! `/protocol/device-manager`. Defined through the envelope
|
||||
//! (docs/os-development/protocol-namespace.md), so every packet — request, reply,
|
||||
//! and pushed event alike — begins with the folded `Header`.
|
||||
//!
|
||||
//! **`Header.target` is the device id.** It was the `device_id` field of three
|
||||
//! different messages; folding it into the header is what made the packed
|
||||
//! leading operation byte disappear along with it. `no_device` addresses a
|
||||
//! driver that serves no enumerated device.
|
||||
//!
|
||||
//! Two of the manager's four old operations were the reserved verbs under
|
||||
//! another name and are gone from this protocol's own numbering: `enumerate`
|
||||
//! (the tree, one `ChildEntry` per record in the reply tail) and `subscribe`
|
||||
//! (the watcher's endpoint rides as the call's capability). What is left is the
|
||||
//! driver-facing half — the handshake and the two tree reports.
|
||||
//!
|
||||
//! **`ChildAdded` travels in both directions, and says so twice.** A bus driver
|
||||
//! *calls* `child_added` to report a device; the manager then *pushes* the same
|
||||
//! struct to every subscriber as the `child_added` event. Operations and events
|
||||
//! are numbered in separate spaces, so one struct under two numbers is exactly
|
||||
//! how the envelope spells "one encoding, both directions" — and the direction
|
||||
//! (call vs. send) already tells them apart.
|
||||
//!
|
||||
//! Deliberately contains nothing lifecycle-shaped: stopping, liveness (the
|
||||
//! zero-length ping), and exit reasons are the universal vocabulary of
|
||||
//! docs/process-lifecycle.md, not this protocol.
|
||||
|
||||
/// The protocol version a driver states in its hello. A manager that cannot
|
||||
/// serve a driver's version refuses the hello, and the mismatch is loud at
|
||||
/// startup instead of quiet corruption later.
|
||||
pub const version: u16 = 1;
|
||||
const std = @import("std");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// The protocol version a driver states in its hello, and the version this
|
||||
/// contract answers `describe` with. A manager that cannot serve a driver's
|
||||
/// version refuses the hello, and the mismatch is loud at startup instead of
|
||||
/// quiet corruption later.
|
||||
///
|
||||
/// `describe` publishes the same number, but it cannot replace this: it tells a
|
||||
/// *client* what the provider is, and here it is the **provider** that has to
|
||||
/// learn what the client was built against in order to refuse it.
|
||||
pub const version = 1;
|
||||
|
||||
/// `Header.target` for a driver that serves no enumerated device (a test
|
||||
/// fixture, a synthetic source), and `ChildAdded`'s answer for a leaf that was
|
||||
/// never `device_register`ed.
|
||||
pub const no_device: u64 = ~@as(u64, 0);
|
||||
|
||||
/// Which bus a `child_added` came from — stated by the reporting bus driver so
|
||||
/// the manager's /etc/devices.csv matcher knows how to read the report's identity
|
||||
/// the manager's /system/configuration/devices.csv matcher knows how to read the report's identity
|
||||
/// (a PCI class triple vs a USB class triple are the same 24 bits but different
|
||||
/// namespaces) and which `bus` column a rule must name to bind it. `unknown` is
|
||||
/// the zero default, so an un-upgraded reporter fails to match rather than
|
||||
@@ -24,7 +57,7 @@ pub const BusKind = enum(u8) {
|
||||
acpi = 3,
|
||||
};
|
||||
|
||||
/// What kind of driver is talking (docs/driver-model.md's shapes).
|
||||
/// What kind of driver is talking (docs/device-driver-development/driver-model.md's shapes).
|
||||
pub const Role = enum(u8) {
|
||||
/// Owns a controller and reports the devices behind it (`child_added`).
|
||||
bus = 1,
|
||||
@@ -32,58 +65,45 @@ pub const Role = enum(u8) {
|
||||
device = 2,
|
||||
};
|
||||
|
||||
/// The message kinds.
|
||||
pub const Operation = enum(u8) {
|
||||
hello = 1,
|
||||
child_added = 2,
|
||||
child_removed = 3,
|
||||
enumerate = 4,
|
||||
subscribe = 5,
|
||||
};
|
||||
|
||||
/// `Hello.device_id` for a driver that serves no enumerated device (a test
|
||||
/// fixture, a synthetic source).
|
||||
pub const no_device: u64 = ~@as(u64, 0);
|
||||
// --- the per-operation request parts ----------------------------------------
|
||||
//
|
||||
// Each names the bytes AFTER the prefix. Nothing here carries an operation or a
|
||||
// device id: those are the packet header's, folded in once. No reply part
|
||||
// carries a status either — that is the `Status` every reply already begins
|
||||
// with, so the manager's old three `{status, reserved}` reply structs are gone.
|
||||
|
||||
/// The handshake, sent once by every driver the manager spawns — the manager's
|
||||
/// one self-enforced deadline: spawned and silent past it means wrong binary,
|
||||
/// wrong version, or wedged before main, and the stop sequence follows.
|
||||
/// wrong version, or wedged before main, and the stop sequence follows. The
|
||||
/// device this driver was assigned (its argv[1]) is `Header.target`.
|
||||
pub const Hello = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.hello),
|
||||
/// A Role value.
|
||||
/// A `Role` value.
|
||||
role: u8,
|
||||
_padding: u8 = 0,
|
||||
/// The protocol version this driver was built against (`version`).
|
||||
version: u16 = version,
|
||||
reserved: u32 = 0,
|
||||
/// The device this driver was assigned (its argv[1]), or `no_device`.
|
||||
device_id: u64,
|
||||
};
|
||||
|
||||
pub const hello_size = @sizeOf(Hello);
|
||||
|
||||
/// The manager's answer to a hello. Nonzero status = refused (version mismatch,
|
||||
/// unknown sender); a refused driver should exit cleanly.
|
||||
pub const HelloReply = extern struct {
|
||||
status: i32,
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
|
||||
pub const reply_size = @sizeOf(HelloReply);
|
||||
|
||||
/// A bus driver reporting one device it discovered behind its controller
|
||||
/// (docs/device-manager.md "the tree"). Identity is the bus's native language —
|
||||
/// for USB a port-speed class; the (class, subclass, protocol) triple joins it
|
||||
/// once control transfers exist (the USB track). The manager mirrors the child
|
||||
/// into its tree; when the reporting driver dies, the manager prunes everything
|
||||
/// it reported (the children describe protocol state that died with it) and the
|
||||
/// restarted instance rediscovers and re-reports.
|
||||
/// (docs/device-driver-development/device-manager.md "the tree"), and the payload
|
||||
/// the manager pushes to its subscribers for the same event. Identity is the
|
||||
/// bus's native language — for USB a port-speed class, for PCI the class triple.
|
||||
/// The manager mirrors the child into its tree; when the reporting driver dies,
|
||||
/// the manager prunes everything it reported (the children describe protocol
|
||||
/// state that died with it) and the restarted instance rediscovers and
|
||||
/// re-reports.
|
||||
///
|
||||
/// `Header.target` is the kernel device id this child was `device_register`ed
|
||||
/// as — what the manager hands a matched driver as its argv assignment — or
|
||||
/// `no_device` for an unregistered leaf (a USB port before the descriptor
|
||||
/// track). That is the field that used to sit at the end of this struct.
|
||||
///
|
||||
/// **The field order is the size budget.** An event packet is the header plus
|
||||
/// this, within 64 bytes, and three `u64`s round the whole struct up to a
|
||||
/// multiple of eight whatever order they sit in — so the small fields are
|
||||
/// packed tail-first into the space the rounding pays for anyway. `Define`
|
||||
/// checks the result; this comment is why there is no slack in it.
|
||||
pub const ChildAdded = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.child_added),
|
||||
/// A `BusKind` value: which bus reported this child, so the manager reads the
|
||||
/// identity in the right namespace and matches against the right `bus` column.
|
||||
bus: u8 = @intFromEnum(BusKind.unknown),
|
||||
reserved1: u16 = 0,
|
||||
reserved2: u32 = 0,
|
||||
/// The reporting driver's own device (the controller) — the child's parent.
|
||||
parent: u64,
|
||||
/// Where on the bus (for USB: the root port number, 1-based).
|
||||
@@ -91,85 +111,130 @@ pub const ChildAdded = extern struct {
|
||||
/// Bus-specific identity (for USB: the PORTSC port-speed class; for PCI:
|
||||
/// the class triple; for ACPI devices, 0 — identity is the hid below).
|
||||
identity: u64,
|
||||
/// The kernel device id this child was `device_register`ed as — what the
|
||||
/// manager hands a matched driver as its argv assignment — or `no_device`
|
||||
/// for an unregistered leaf (a USB port before the descriptor track).
|
||||
device_id: u64 = no_device,
|
||||
/// The PCI subsystem id, packed `(subsystem_vendor << 16) | subsystem_device`
|
||||
/// (so it reads vendor-first, matching the CSV's `ssvid:ssid`), or 0 when the
|
||||
/// device has no subsystem id (a bridge, or a non-PCI bus).
|
||||
subsystem: u32 = 0,
|
||||
/// The vendor id (PCI vendor / USB idVendor), or 0 when the bus has no such
|
||||
/// concept (ACPI). Carried so the manager's /etc/devices.csv matcher can bind
|
||||
/// concept (ACPI). Carried so the manager's /system/configuration/devices.csv matcher can bind
|
||||
/// on vendor — a level the bus-native `identity` (a class triple) cannot express.
|
||||
vendor: u16 = 0,
|
||||
/// The device id (PCI device / USB idProduct), or 0. The most specific numeric
|
||||
/// level: this is what lets one virtio-gpu (1AF4:1050) be told from any other
|
||||
/// virtio display function without the driver re-confirming after it is spawned.
|
||||
device: u16 = 0,
|
||||
/// The PCI subsystem id, packed `(subsystem_vendor << 16) | subsystem_device`
|
||||
/// (so it reads vendor-first, matching the CSV's `ssvid:ssid`), or 0 when the
|
||||
/// device has no subsystem id (a bridge, or a non-PCI bus).
|
||||
subsystem: u32 = 0,
|
||||
/// The ACPI hardware id (`_HID`), EISA-decoded (e.g. "PNP0303"), for devices
|
||||
/// discovered by firmware string rather than a numeric bus identity. Empty
|
||||
/// (all zero) otherwise. Widens for FDT `compatible` strings later.
|
||||
hid: [8]u8 = .{0} ** 8,
|
||||
/// A `BusKind` value: which bus reported this child, so the manager reads the
|
||||
/// identity in the right namespace and matches against the right `bus` column.
|
||||
bus: u8 = @intFromEnum(BusKind.unknown),
|
||||
_padding: [7]u8 = .{0} ** 7,
|
||||
};
|
||||
|
||||
pub const child_added_size = @sizeOf(ChildAdded);
|
||||
|
||||
/// A bus driver reporting a device gone (hot-unplug). Not yet sent by any
|
||||
/// driver — the port scan has no unplug interrupt — but the manager handles it;
|
||||
/// death-pruning covers removal until hotplug lands.
|
||||
/// A bus driver reporting a device gone (hot-unplug), and the payload pushed to
|
||||
/// subscribers for it.
|
||||
///
|
||||
/// **This is the one message whose target stays 0.** A removal is addressed by
|
||||
/// the composite (parent, bus address) — the reporter knows where the device
|
||||
/// *was*, not necessarily what id it had been registered under — and a single
|
||||
/// `u64` cannot carry a pair. So the address stays in the payload, where it
|
||||
/// always was, and the header addresses the provider itself.
|
||||
pub const ChildRemoved = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.child_removed),
|
||||
reserved0: u8 = 0,
|
||||
reserved1: u16 = 0,
|
||||
reserved2: u32 = 0,
|
||||
parent: u64,
|
||||
bus_address: u64,
|
||||
};
|
||||
|
||||
pub const child_removed_size = @sizeOf(ChildRemoved);
|
||||
|
||||
/// The manager's answer to a tree report.
|
||||
pub const ReportReply = extern struct {
|
||||
status: i32,
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
|
||||
/// An application asking for the tree (M18.3): the reply is an EnumerateReply
|
||||
/// header followed by `count` ChildEntry records.
|
||||
pub const Enumerate = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.enumerate),
|
||||
reserved0: u8 = 0,
|
||||
reserved1: u16 = 0,
|
||||
reserved2: u32 = 0,
|
||||
};
|
||||
|
||||
pub const EnumerateReply = extern struct {
|
||||
status: i32,
|
||||
/// ChildEntry records following this header.
|
||||
count: u32,
|
||||
};
|
||||
|
||||
/// One record of the reserved `enumerate` reply: the manager's mirror, one
|
||||
/// entry per known child, packed into the reply tail. The count is
|
||||
/// `Status.len / @sizeOf(ChildEntry)` — the envelope's reply length says how
|
||||
/// many arrived, so no count header is spent on saying it twice.
|
||||
pub const ChildEntry = extern struct {
|
||||
parent: u64,
|
||||
bus_address: u64,
|
||||
identity: u64,
|
||||
};
|
||||
|
||||
/// An application subscribing to published add/remove events (the input-service
|
||||
/// pattern): the subscriber's endpoint rides as the call's **capability**, and
|
||||
/// events arrive on it as buffered messages whose payload is the same
|
||||
/// ChildAdded / ChildRemoved struct the bus drivers send — one encoding, both
|
||||
/// directions.
|
||||
pub const Subscribe = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.subscribe),
|
||||
reserved0: u8 = 0,
|
||||
reserved1: u16 = 0,
|
||||
reserved2: u32 = 0,
|
||||
};
|
||||
/// How many `ChildEntry` records one `enumerate` reply can carry. Paging joins
|
||||
/// the protocol if a tree ever outgrows one packet.
|
||||
pub const entries_per_reply: usize = (envelope.packet_maximum - envelope.prefix_size) / @sizeOf(ChildEntry);
|
||||
|
||||
/// Upper bound on any message in this protocol — sizes the endpoint buffers.
|
||||
/// Capped by the kernel's IPC MESSAGE_MAXIMUM (256): an EnumerateReply carries
|
||||
/// up to ten ChildEntry records per call, plenty for the mirror's current
|
||||
/// bounds; paging joins the protocol if a tree ever outgrows one message.
|
||||
pub const message_maximum = 256;
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "device-manager",
|
||||
.version = version,
|
||||
.operations = &.{
|
||||
// The driver-facing half. `enumerate` and `subscribe` are not here: they
|
||||
// are the reserved verbs, which mean the same thing at every provider.
|
||||
.{ .name = "hello", .request = Hello },
|
||||
.{ .name = "child_added", .request = ChildAdded },
|
||||
.{ .name = "child_removed", .request = ChildRemoved },
|
||||
},
|
||||
.events = &.{
|
||||
// The watcher-facing half — the same two structs, pushed rather than
|
||||
// called, in the events' own numbering space.
|
||||
.{ .name = "child_added", .payload = ChildAdded },
|
||||
.{ .name = "child_removed", .payload = ChildRemoved },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
pub const Event = Protocol.Event;
|
||||
|
||||
/// What the manager sizes its buffers to — the call floor, as every protocol does.
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
test "a tree report fits the push floor with the header folded in" {
|
||||
// The dual-use struct is the tight one: `child_added` is both a call and an
|
||||
// event, and the event floor is 64 bytes *including* the header. Forty-one
|
||||
// bytes of content, rounded to 48 by the three u64s' alignment, plus the
|
||||
// 16-byte header — exactly on the floor, which is what folding the operation
|
||||
// byte and the device id out of the payload bought.
|
||||
try std.testing.expectEqual(@as(usize, 48), @sizeOf(ChildAdded));
|
||||
try std.testing.expectEqual(envelope.post_maximum, Protocol.event_maximum);
|
||||
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
|
||||
// Ten records per enumerate reply — what the old count-header layout carried.
|
||||
try std.testing.expectEqual(@as(usize, 10), entries_per_reply);
|
||||
}
|
||||
|
||||
test "the verb and event numbering, and the device id in the header" {
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.hello));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.child_added));
|
||||
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.child_removed));
|
||||
// Events number in their own space, so the same two reports start at 16 too.
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.child_added));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.child_removed));
|
||||
// The manager's own enumerate/subscribe became the RESERVED verbs, below the
|
||||
// protocol range entirely.
|
||||
try std.testing.expectEqual(@as(u32, 1), envelope.operation_enumerate);
|
||||
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
|
||||
|
||||
var buffer: [message_maximum]u8 = undefined;
|
||||
const hello = Protocol.encodeRequest(.hello, 7, .{ .role = @intFromEnum(Role.bus) }, &.{}, &buffer).?;
|
||||
try std.testing.expectEqual(@as(u64, 7), envelope.headerOf(hello).?.target);
|
||||
try std.testing.expectEqual(@as(u16, 1), Protocol.decodeRequest(.hello, hello).?.version);
|
||||
}
|
||||
|
||||
test "one struct, two numbers: the report a bus calls and the event a watcher is pushed" {
|
||||
const report = ChildAdded{
|
||||
.parent = 3,
|
||||
.bus_address = 1,
|
||||
.identity = 0x030000,
|
||||
.bus = @intFromEnum(BusKind.pci),
|
||||
.vendor = 0x1AF4,
|
||||
};
|
||||
|
||||
var call: [message_maximum]u8 = undefined;
|
||||
const called = Protocol.encodeRequest(.child_added, 42, report, &.{}, &call).?;
|
||||
try std.testing.expectEqual(Operation.child_added, Protocol.operationOf(called).?);
|
||||
try std.testing.expectEqual(@as(u64, 42), envelope.headerOf(called).?.target);
|
||||
|
||||
var push: [envelope.post_maximum]u8 = undefined;
|
||||
const pushed = Protocol.encodeEvent(.child_added, 42, report, &push).?;
|
||||
try std.testing.expectEqual(envelope.post_maximum, pushed.len);
|
||||
try std.testing.expectEqual(Event.child_added, Protocol.eventOf(pushed).?);
|
||||
try std.testing.expectEqual(@as(u16, 0x1AF4), Protocol.decodeEvent(.child_added, pushed).?.vendor);
|
||||
// Same bytes after the prefix, different verb in it — the direction is what
|
||||
// tells a call from a push, and the numbering spaces never collide.
|
||||
try std.testing.expectEqualSlices(u8, called[envelope.prefix_size..], pushed[envelope.prefix_size..]);
|
||||
}
|
||||
|
||||
@@ -1,93 +1,137 @@
|
||||
//! The display wire protocol — what a client says to the display service over its
|
||||
//! well-known `.display` endpoint. extern-struct messages with an `Operation` tag, the
|
||||
//! same shape as block/vfs/input protocols. The compositor owns the framebuffer and an
|
||||
//! ordered stack of **layers**; a client creates layers, draws into them with these
|
||||
//! operations, marks damage, and asks for a `present`. v1 surfaces are server-owned (a
|
||||
//! client draws by command); shared-memory surfaces are a later milestone (docs/display.md).
|
||||
//! The display wire protocol — what a client says to the display service over
|
||||
//! `/protocol/display`. The compositor owns the framebuffer and an ordered stack of
|
||||
//! **layers**; a client creates layers, draws into them with these operations, marks damage,
|
||||
//! and asks for a `present`. v1 surfaces are server-owned (a client draws by command);
|
||||
//! shared-memory surfaces are a later milestone (docs/display.md).
|
||||
//!
|
||||
//! **`Header.target` is the layer** on every verb that names one — the field that used to be
|
||||
//! `Request.layer`. `info`, `present`, `set_mode`, `get_modes` and `attach_scanout` address
|
||||
//! the compositor itself, so they leave it 0.
|
||||
//!
|
||||
//! Every verb carries its own request type. The single overloaded 40-byte request this
|
||||
//! protocol used to have is gone, and with it the field abuse it invited: `attach_scanout`
|
||||
//! spent `x` on a stride, `y` on a refresh rate and `colour` on a pixel format, which no
|
||||
//! reader could have guessed and no compiler could have caught.
|
||||
|
||||
const envelope = @import("envelope");
|
||||
const std = @import("std");
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
/// info() -> { width, height, pitch, format }: the display's current mode.
|
||||
info = 0,
|
||||
/// create_layer(x, y, width, height, z) -> { layer }: a new server-owned surface.
|
||||
create_layer = 1,
|
||||
/// configure_layer(layer, x, y, z, visible): move, restack, show, or hide a layer.
|
||||
configure_layer = 2,
|
||||
/// destroy_layer(layer): release a layer.
|
||||
destroy_layer = 3,
|
||||
/// fill_rect(layer, x, y, width, height, colour): fill a rectangle of a layer.
|
||||
fill_rect = 4,
|
||||
/// blit_tile(layer, x, y, width, height, <inline pixels>): copy a small pixel tile in.
|
||||
blit_tile = 5,
|
||||
/// damage(layer, x, y, width, height): mark a region dirty for the next present.
|
||||
damage = 6,
|
||||
/// present(): composite the dirty layers and flush to the screen.
|
||||
present = 7,
|
||||
/// attach_scanout(x=stride, y=refresh_hz, width, height, colour=format) + <surface
|
||||
/// capability>: a native scanout driver announces itself, handing over the shared scanout
|
||||
/// surface as an `ipc_call` send_cap. The compositor maps it, looks up the driver's
|
||||
/// `.scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md V4).
|
||||
/// `x` is the surface's row stride in pixels, `y` the panel refresh rate from the
|
||||
/// driver's EDID read (0 = unknown; paces the compositor's frame clock), `colour` the
|
||||
/// DisplayFormat.
|
||||
attach_scanout = 8,
|
||||
/// set_mode(width, height): change the display resolution — only a native backend that
|
||||
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
|
||||
set_mode = 9,
|
||||
/// get_modes() -> ModesReply: the resolutions the display can switch to (empty on GOP).
|
||||
get_modes = 10,
|
||||
};
|
||||
|
||||
/// The fixed request header. A `blit_tile`'s pixel payload (width*height 32-bit pixels)
|
||||
/// follows this header inline in the same message, up to `maximum_payload`.
|
||||
pub const Request = extern struct {
|
||||
operation: u32,
|
||||
layer: u32 = 0, // create/configure/destroy/fill/blit/damage: the target layer
|
||||
x: u32 = 0,
|
||||
y: u32 = 0,
|
||||
/// The answer to `info()`: the display's current mode.
|
||||
pub const Info = extern struct {
|
||||
width: u32 = 0,
|
||||
height: u32 = 0,
|
||||
z: u32 = 0, // create_layer / configure_layer: stacking order (higher = in front)
|
||||
colour: u32 = 0, // fill_rect: the fill colour (native pixel value)
|
||||
visible: u32 = 1, // configure_layer: 0 hides the layer
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
|
||||
pub const Reply = extern struct {
|
||||
status: i32, // 0 on success, negative on failure
|
||||
reserved: u32 = 0,
|
||||
// info():
|
||||
width: u32 = 0,
|
||||
height: u32 = 0,
|
||||
pitch: u32 = 0,
|
||||
pitch: u32 = 0, // bytes per row (may exceed width*4)
|
||||
format: u32 = 0, // a device-abi DisplayFormat value (0 = rgbx, 1 = bgrx)
|
||||
// create_layer():
|
||||
layer: u32 = 0,
|
||||
reserved2: u32 = 0,
|
||||
};
|
||||
|
||||
/// `create_layer(...)`: a new server-owned surface. Coordinates are signed — a layer may sit
|
||||
/// partly off-screen.
|
||||
pub const CreateLayer = extern struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
z: u32 = 0, // stacking order (higher = nearer the front)
|
||||
visible: u32 = 1,
|
||||
};
|
||||
|
||||
/// The layer a `create_layer` established — the integer later packets put in `Header.target`.
|
||||
pub const Created = extern struct { layer: u32 };
|
||||
|
||||
/// `configure_layer(...)` on `Header.target`: move, restack, show, or hide it.
|
||||
pub const ConfigureLayer = extern struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
z: u32 = 0,
|
||||
visible: u32 = 1, // 0 hides the layer
|
||||
};
|
||||
|
||||
/// `fill_rect(...)` on `Header.target`: fill a layer-local rectangle with a native pixel value.
|
||||
pub const FillRect = extern struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
colour: u32,
|
||||
};
|
||||
|
||||
/// `blit_tile(...)` on `Header.target`: copy a `width`×`height` tile of native pixels
|
||||
/// (row-major, little-endian) into the layer. The pixels ride inline as the packet's tail,
|
||||
/// up to `maximum_payload`.
|
||||
pub const BlitTile = extern struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
};
|
||||
|
||||
/// `damage(...)` on `Header.target`: mark a layer-local region dirty for the next present.
|
||||
pub const Damage = extern struct {
|
||||
x: i32,
|
||||
y: i32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
};
|
||||
|
||||
/// `attach_scanout(...)` + the shared surface as the call's capability: a native scanout
|
||||
/// driver announces itself. The compositor maps the surface, opens the driver's
|
||||
/// `/protocol/scanout` present channel, and upgrades off the GOP floor (docs/display-v2.md
|
||||
/// V4). Each field says what it is, which the old shared request could not.
|
||||
pub const AttachScanout = extern struct {
|
||||
/// The surface's row stride in pixels (it is sized to the driver's largest mode).
|
||||
stride: u32,
|
||||
/// The active mode within that surface.
|
||||
width: u32,
|
||||
height: u32,
|
||||
/// A device-abi DisplayFormat value.
|
||||
format: u32,
|
||||
/// The panel refresh rate from the driver's EDID read (0 = unknown); it paces the
|
||||
/// compositor's frame clock.
|
||||
refresh_hz: u32 = 0,
|
||||
};
|
||||
|
||||
/// `set_mode(width, height)`: change the display resolution — only a native backend that
|
||||
/// reports `canModeSet` honours it; on the GOP floor it fails (docs/display-v2.md V5).
|
||||
pub const SetMode = extern struct { width: u32, height: u32 };
|
||||
|
||||
/// One selectable display mode.
|
||||
pub const Mode = extern struct { width: u32, height: u32 };
|
||||
pub const max_modes = 4;
|
||||
|
||||
/// The reply to `get_modes`: a small fixed list of resolutions the display can switch to.
|
||||
pub const ModesReply = extern struct {
|
||||
status: i32,
|
||||
count: u32,
|
||||
modes: [max_modes]Mode,
|
||||
/// The answer to `get_modes`: the resolutions the display can switch to (empty on GOP).
|
||||
pub const Modes = extern struct {
|
||||
count: u32 = 0,
|
||||
_padding: u32 = 0,
|
||||
modes: [max_modes]Mode = @splat(.{ .width = 0, .height = 0 }),
|
||||
};
|
||||
pub const modes_reply_size: usize = @sizeOf(ModesReply);
|
||||
|
||||
/// The IPC message size — the kernel caps every message at `MESSAGE_MAXIMUM` (256 bytes,
|
||||
/// system/kernel/ipc-synchronous.zig), so this matches it (a larger receive/reply buffer
|
||||
/// is rejected with -E2BIG). A `blit_tile` therefore carries only a *small* tile inline —
|
||||
/// `maximum_payload` bytes = up to 54 pixels, enough for a cursor or small sprite; larger
|
||||
/// bitmaps are the deferred shared-memory surface path (docs/display.md).
|
||||
pub const message_maximum: usize = 256;
|
||||
pub const request_size: usize = @sizeOf(Request);
|
||||
pub const reply_size: usize = @sizeOf(Reply);
|
||||
pub const maximum_payload: usize = message_maximum - request_size;
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "display",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "info", .reply = Info },
|
||||
.{ .name = "create_layer", .request = CreateLayer, .reply = Created },
|
||||
.{ .name = "configure_layer", .request = ConfigureLayer },
|
||||
.{ .name = "destroy_layer" },
|
||||
.{ .name = "fill_rect", .request = FillRect },
|
||||
.{ .name = "blit_tile", .request = BlitTile },
|
||||
.{ .name = "damage", .request = Damage },
|
||||
.{ .name = "present" },
|
||||
.{ .name = "attach_scanout", .request = AttachScanout },
|
||||
.{ .name = "set_mode", .request = SetMode },
|
||||
.{ .name = "get_modes", .reply = Modes },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
/// The largest inline pixel tile a `blit_tile` may carry: the call floor less the header and
|
||||
/// this verb's own fixed part — 224 bytes, up to 56 pixels, enough for a cursor or a small
|
||||
/// sprite. Larger bitmaps are the deferred shared-memory surface path (docs/display.md).
|
||||
/// Per-verb rather than protocol-wide, because with per-operation requests there is no
|
||||
/// single "request size" to subtract any more.
|
||||
pub const maximum_payload: usize = envelope.packet_maximum - envelope.prefix_size - @sizeOf(BlitTile);
|
||||
|
||||
/// Pack an 8-bit-per-channel colour into the display's native 32-bit pixel for `format`
|
||||
/// (a device-abi `DisplayFormat`: 0 = rgbx, 1 = bgrx). Shared so a `colour` in a
|
||||
@@ -113,3 +157,15 @@ test "pack encodes native byte order for rgbx and bgrx" {
|
||||
try std.testing.expectEqual(@as(u32, 0x00AA_0000), pack(1, 0xAA, 0, 0));
|
||||
try std.testing.expectEqual(@as(u32, 0x0000_3020), pack(0, 0x20, 0x30, 0)); // green in byte 1
|
||||
}
|
||||
|
||||
test "the layer rides the header, and the blit tile grew with the split" {
|
||||
var buffer: [message_maximum]u8 = undefined;
|
||||
const pixels = [_]u8{0xFF} ** 16;
|
||||
const packet = Protocol.encodeRequest(.blit_tile, 3, .{ .x = 1, .y = 2, .width = 2, .height = 2 }, &pixels, &buffer).?;
|
||||
try std.testing.expectEqual(@as(u64, 3), envelope.headerOf(packet).?.target);
|
||||
try std.testing.expectEqual(@as(i32, 1), Protocol.decodeRequest(.blit_tile, packet).?.x);
|
||||
try std.testing.expectEqual(@as(usize, 16), Protocol.requestTail(.blit_tile, packet).len);
|
||||
// 216 bytes under the old 40-byte shared request; the header plus this
|
||||
// verb's own four fields is 32.
|
||||
try std.testing.expectEqual(@as(usize, 224), maximum_payload);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,974 @@
|
||||
//! 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 optional body of a reserved `subscribe`: **which** of a provider's events
|
||||
/// the subscriber wants, as a bit mask whose meaning the protocol defines (the
|
||||
/// input service's device classes are the model). A reserved verb carries no
|
||||
/// typed request, so this rides the packet's tail — and zero, which is also what
|
||||
/// a subscribe that sent no body at all reads as, means *every* event.
|
||||
///
|
||||
/// The mask lives here rather than in each protocol because the subscriber
|
||||
/// machinery is the service harness's (library/kernel/service.zig): the harness
|
||||
/// records the number, the protocol decides what its bits mean, and neither has
|
||||
/// to know the other.
|
||||
pub const Subscription = extern struct { interest: u32 = 0 };
|
||||
|
||||
/// Frame a `subscribe` request. The subscriber's own endpoint travels as the
|
||||
/// call's *capability*, never in the packet — that is what makes the reverse
|
||||
/// path unforgeable.
|
||||
pub fn encodeSubscribe(interest: u32, buffer: []u8) ?[]u8 {
|
||||
const header = Header{ .operation = operation_subscribe };
|
||||
const body = Subscription{ .interest = interest };
|
||||
return frame(std.mem.asBytes(&header), std.mem.asBytes(&body), &.{}, buffer);
|
||||
}
|
||||
|
||||
/// Frame a bare `unsubscribe`: it names no event and no endpoint, because it
|
||||
/// means "every subscription this task holds here" (one task, one voice).
|
||||
pub fn encodeUnsubscribe(buffer: []u8) ?[]u8 {
|
||||
const header = Header{ .operation = operation_unsubscribe };
|
||||
return frame(std.mem.asBytes(&header), &.{}, &.{}, buffer);
|
||||
}
|
||||
|
||||
/// The interest mask out of a `subscribe` packet's tail, on the provider's side.
|
||||
/// A caller that sent no mask reads as the every-event mask.
|
||||
pub fn decodeSubscribe(tail: []const u8) Subscription {
|
||||
if (tail.len < @sizeOf(Subscription)) return .{};
|
||||
return std.mem.bytesToValue(Subscription, tail[0..@sizeOf(Subscription)]);
|
||||
}
|
||||
|
||||
/// 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
|
||||
pub const EBUSY: i32 = 12; // the thing asked for is held by someone still alive
|
||||
|
||||
/// Restated from the kernel's half of the numbering, because a provider refuses
|
||||
/// too and userspace has no other place to read these from: `ENOENT` is "no such
|
||||
/// name", `EPERM` "not permitted". The protocol registry answers an ungranted
|
||||
/// bind with the second and a name a live provider already holds with `EBUSY`.
|
||||
pub const ENOENT: i32 = 4;
|
||||
pub const ENOSPC: i32 = 5;
|
||||
pub const EPERM: i32 = 9;
|
||||
|
||||
// --- 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 "a subscribe carries its interest mask in the reserved verb's tail" {
|
||||
var buffer: [packet_maximum]u8 = undefined;
|
||||
const packet = encodeSubscribe(0b101, &buffer).?;
|
||||
try testing.expectEqual(operation_subscribe, headerOf(packet).?.operation);
|
||||
try testing.expectEqual(@as(u32, 0b101), decodeSubscribe(packet[prefix_size..]).interest);
|
||||
// No body at all — and a body too short to be one — read as "every event",
|
||||
// which is what a subscriber that named nothing wants.
|
||||
try testing.expectEqual(@as(u32, 0), decodeSubscribe(&.{}).interest);
|
||||
try testing.expectEqual(@as(u32, 0), decodeSubscribe(&.{ 1, 2 }).interest);
|
||||
|
||||
const bare = encodeUnsubscribe(&buffer).?;
|
||||
try testing.expectEqual(operation_unsubscribe, headerOf(bare).?.operation);
|
||||
try testing.expectEqual(prefix_size, bare.len);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -4,25 +4,30 @@
|
||||
//! **subscriber** (any program) that subscribes and is then pushed each event.
|
||||
//!
|
||||
//! The service handles several device classes over one endpoint. Each class has its own
|
||||
//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); they all travel in a common
|
||||
//! `InputEvent` envelope tagged with a `DeviceKind`, so the fan-out path is one code path
|
||||
//! and a subscriber can take a mix of devices on a single stream. A subscriber declares
|
||||
//! which classes it wants with a `device_mask`, and the service routes accordingly.
|
||||
//! typed event (`KeyEvent`, `MouseEvent`, `JoystickEvent`); a subscriber declares which
|
||||
//! classes it wants with a `device_mask`, and the service routes accordingly.
|
||||
//!
|
||||
//! Two message shapes ride over the endpoint, tagged by `Operation`, like the
|
||||
//! [VFS protocol](../vfs/protocol.zig):
|
||||
//! Three shapes ride over the channel, and the envelope names all three
|
||||
//! (docs/os-development/protocol-namespace.md):
|
||||
//!
|
||||
//! - **subscribe / publish**: a synchronous `ipc_call` carrying a `Request`. `subscribe`
|
||||
//! hands the service the subscriber's own endpoint as a capability (`send_cap`) and a
|
||||
//! `device_mask`; `publish` carries an `InputEvent`. The reply is a `Reply`.
|
||||
//! - **delivery**: the service pushes each `InputEvent` to every interested subscriber with
|
||||
//! the asynchronous `ipc_send` — no reply owed, and a dead subscriber can never stall the
|
||||
//! broadcast. Received in the subscriber's buffer with `Received.isMessage()` set.
|
||||
//! - **subscribe** is the *reserved* verb, not one of this protocol's own: its shape — a
|
||||
//! synchronous call whose attached capability is the subscriber's endpoint — is exactly
|
||||
//! what `envelope.operation_subscribe` means everywhere. The interest mask travels as the
|
||||
//! packet's tail (`envelope.Subscription`), because a reserved verb carries no typed
|
||||
//! request; what this protocol supplies is the *meaning* of its bits — the device classes.
|
||||
//! - **publish** is this protocol's one verb: a source sends one `InputEvent` and the
|
||||
//! service answers at once, so publishing never blocks on a slow subscriber.
|
||||
//! - **delivery** is an event push: the service `ipc_send`s each event to every interested
|
||||
//! subscriber — no reply owed, so a dead subscriber can never stall the broadcast. The
|
||||
//! packet is the folded header plus the typed event, and **the device class is the
|
||||
//! header's operation**: one event per class, so a subscriber reads the kind from the
|
||||
//! packet rather than from a tag inside the payload.
|
||||
//!
|
||||
//! This is a danos-native contract, shared by the input service, the `runtime.input`
|
||||
//! client helpers, and every source/subscriber. Everything fits one IPC message.
|
||||
//! `Header.target` is unused (0) in both directions: the service is the only object either
|
||||
//! side addresses.
|
||||
|
||||
const std = @import("std");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// The classes of input device the service fans out. Each names a typed event and a bit in
|
||||
/// the subscription mask.
|
||||
@@ -242,14 +247,17 @@ pub const JoystickEvent = extern struct {
|
||||
buttons: u32, // current pressed-button bitmask
|
||||
};
|
||||
|
||||
// --- the common envelope ----------------------------------------------------
|
||||
// --- the tagged union of the three ------------------------------------------
|
||||
|
||||
/// The largest per-device event, so `InputEvent` can hold any of them inline.
|
||||
pub const max_event_size: usize = @max(@sizeOf(KeyEvent), @max(@sizeOf(MouseEvent), @sizeOf(JoystickEvent)));
|
||||
|
||||
/// The tagged envelope broadcast to subscribers: a `DeviceKind` plus the raw bytes of the
|
||||
/// matching per-device event. Decode it with `asKeyboard`/`asMouse`/`asJoystick` (each
|
||||
/// returns null unless `device` matches), or build one with the `from*` constructors.
|
||||
/// One event of any class: a `DeviceKind` plus the raw bytes of the matching per-device
|
||||
/// event. This is what a source `publish`es (one verb for all three classes) and what a
|
||||
/// subscriber's helper hands back after decoding a delivery — on the *delivery* wire the
|
||||
/// class is the packet header's operation instead, so this tag never travels there. Decode
|
||||
/// it with `asKeyboard`/`asMouse`/`asJoystick` (each returns null unless `device` matches),
|
||||
/// or build one with the `from*` constructors.
|
||||
pub const InputEvent = extern struct {
|
||||
device: u32, // a DeviceKind
|
||||
_padding: u32 = 0,
|
||||
@@ -285,35 +293,88 @@ pub const InputEvent = extern struct {
|
||||
}
|
||||
};
|
||||
|
||||
// --- request / reply --------------------------------------------------------
|
||||
// --- the contract -----------------------------------------------------------
|
||||
|
||||
/// Which side of a request this is.
|
||||
pub const Operation = enum(u32) {
|
||||
subscribe = 0, // register the caller's endpoint (send_cap) for the classes in device_mask
|
||||
publish = 1, // a source submits `event` to broadcast to interested subscribers
|
||||
};
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "input",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
// A source submits one event; the service broadcasts it to whoever wants that class.
|
||||
.{ .name = "publish", .request = InputEvent },
|
||||
},
|
||||
.events = &.{
|
||||
// One per device class: the class is the packet's operation, the typed event its
|
||||
// payload. The push floor is 64 bytes and the header spends 16 of them, so the
|
||||
// widest of these — the 28-byte mouse event — leaves the budget with room to spare.
|
||||
.{ .name = "keyboard", .payload = KeyEvent },
|
||||
.{ .name = "mouse", .payload = MouseEvent },
|
||||
.{ .name = "joystick", .payload = JoystickEvent },
|
||||
},
|
||||
});
|
||||
|
||||
/// Request header. For `subscribe`, `device_mask` is the OR of `device_*` bits the caller
|
||||
/// wants (0 means all) and the caller's receive endpoint travels as the call's capability;
|
||||
/// `event` is ignored. For `publish`, `event` is the event to broadcast.
|
||||
pub const Request = extern struct {
|
||||
operation: u32, // an Operation
|
||||
device_mask: u32 = 0, // subscribe: interested device classes (0 => all)
|
||||
event: InputEvent = .{ .device = 0 },
|
||||
};
|
||||
|
||||
/// Reply header. `status` is 0 on success or a negative errno.
|
||||
pub const Reply = extern struct {
|
||||
status: i32,
|
||||
_padding: u32 = 0,
|
||||
};
|
||||
|
||||
pub const request_size: usize = @sizeOf(Request);
|
||||
pub const reply_size: usize = @sizeOf(Reply);
|
||||
pub const Operation = Protocol.Operation;
|
||||
pub const Event = Protocol.Event;
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
pub const event_size: usize = @sizeOf(InputEvent);
|
||||
|
||||
comptime {
|
||||
// The delivery path posts a bare InputEvent through ipc_send, so it must fit an
|
||||
// endpoint's async payload slot (POST_MAXIMUM is 64).
|
||||
if (event_size > 64) @compileError("InputEvent must fit the ipc_send payload (POST_MAXIMUM)");
|
||||
/// The event class a `DeviceKind` value (as it appears in `InputEvent.device`) is delivered
|
||||
/// as. Null for a value no class claims, which is delivered to nobody.
|
||||
pub fn eventOfDevice(device: u32) ?Event {
|
||||
return switch (device) {
|
||||
@intFromEnum(DeviceKind.keyboard) => .keyboard,
|
||||
@intFromEnum(DeviceKind.mouse) => .mouse,
|
||||
@intFromEnum(DeviceKind.joystick) => .joystick,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Frame a `subscribe` request: the reserved verb's header, then the interest mask. Null if
|
||||
/// the buffer is too small. The mask itself is the envelope's `Subscription` — the interest
|
||||
/// a reserved subscribe carries is universal, and the *meaning* of its bits (here: the
|
||||
/// device classes above) is what each protocol supplies. Kept as a named helper because
|
||||
/// `device_mask` is what an input caller calls it.
|
||||
pub fn encodeSubscribe(device_mask: u32, buffer: []u8) ?[]u8 {
|
||||
return envelope.encodeSubscribe(device_mask, buffer);
|
||||
}
|
||||
|
||||
test "an event of every class fits the push floor, header included" {
|
||||
// What the hand-rolled comptime assert used to say about `InputEvent`, now
|
||||
// said by `Define` about each typed event — and counting the header, which
|
||||
// the old check did not.
|
||||
try std.testing.expectEqual(envelope.prefix_size + @sizeOf(MouseEvent), Protocol.event_maximum);
|
||||
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
|
||||
}
|
||||
|
||||
test "the verb numbering, and the class an event carries" {
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.publish));
|
||||
// Events number in their own space, so the three classes start at 16 too.
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.keyboard));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.mouse));
|
||||
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Event.joystick));
|
||||
// subscribe is the RESERVED verb, below the protocol range entirely.
|
||||
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
|
||||
|
||||
var buffer: [envelope.post_maximum]u8 = undefined;
|
||||
const packet = Protocol.encodeEvent(.mouse, 0, .{
|
||||
.kind = @intFromEnum(MouseEventKind.motion),
|
||||
.button = 0,
|
||||
.dx = 3,
|
||||
.dy = -4,
|
||||
.scroll_x = 0,
|
||||
.scroll_y = 0,
|
||||
.buttons = 0,
|
||||
}, &buffer).?;
|
||||
try std.testing.expectEqual(Event.mouse, Protocol.eventOf(packet).?);
|
||||
try std.testing.expectEqual(@as(i32, -4), Protocol.decodeEvent(.mouse, packet).?.dy);
|
||||
}
|
||||
|
||||
test "a subscribe carries its mask in the tail of the reserved verb" {
|
||||
var buffer: [envelope.packet_maximum]u8 = undefined;
|
||||
const packet = encodeSubscribe(device_mouse, &buffer).?;
|
||||
try std.testing.expectEqual(envelope.operation_subscribe, envelope.headerOf(packet).?.operation);
|
||||
// The provider side of this is the service harness's, which reads the same
|
||||
// interest mask out of the tail for every protocol.
|
||||
try std.testing.expectEqual(device_mouse, envelope.decodeSubscribe(packet[envelope.prefix_size..]).interest);
|
||||
// A caller that sent nothing at all reads as the every-class mask.
|
||||
try std.testing.expectEqual(@as(u32, 0), envelope.decodeSubscribe(&.{}).interest);
|
||||
}
|
||||
|
||||
@@ -1,68 +1,104 @@
|
||||
//! The power protocol (docs/power.md): system power's domain-named surface,
|
||||
//! registered under `ServiceId.power`. On x86 the acpi service serves it; on
|
||||
//! ARM a PSCI/mailbox service will register the same id — subscribers never
|
||||
//! learn which firmware they are on (docs/discovery.md — firmware neutrality).
|
||||
//! The vfs-protocol pattern: extern-struct messages, a version, reserved fields.
|
||||
//! The power protocol (docs/os-development/power.md): system power's
|
||||
//! domain-named surface, bound at `/protocol/power`. On x86 the acpi service
|
||||
//! provides it; on ARM a PSCI/mailbox service will bind the same name —
|
||||
//! subscribers never learn which firmware they are on (docs/discovery.md —
|
||||
//! firmware neutrality), which is the whole point of naming the contract rather
|
||||
//! than the provider (docs/os-development/protocol-namespace.md).
|
||||
//!
|
||||
//! Defined through the envelope, so every packet begins with the folded
|
||||
//! `Header`. Three shapes ride the channel, and the envelope names all three:
|
||||
//!
|
||||
//! - **subscribe** is the *reserved* verb, not one of this protocol's own: a
|
||||
//! synchronous call whose attached capability is the subscriber's endpoint is
|
||||
//! exactly what `envelope.operation_subscribe` means everywhere.
|
||||
//! - **shutdown** is this protocol's one verb — the only operation that *does*
|
||||
//! something irreversible, and the reason the provider gates it by badge.
|
||||
//! - **the events** are pushes: the service `ipc_send`s each one to every
|
||||
//! subscriber, no reply owed, so a slow or dead subscriber can never wedge the
|
||||
//! source. **The kind is the packet's operation** — one declared event per
|
||||
//! named kind, exactly as the input protocol delivers one per device class —
|
||||
//! so a subscriber reads *what happened* out of the header instead of a tag
|
||||
//! inside the payload. That is what the old `EventMessage`'s two leading bytes
|
||||
//! (an operation byte saying "this is an event", then the kind) fold into.
|
||||
//!
|
||||
//! `Header.target` is unused (0) in both directions: the provider is the only
|
||||
//! object either side addresses. And no packet carries a version any more — the
|
||||
//! reserved `describe` verb is the version handshake, asked once at connect time
|
||||
//! rather than re-carried out of every packet's budget.
|
||||
|
||||
/// The protocol version a client states nowhere yet — reserved for the day a
|
||||
/// handshake needs it; requests carry it so a mismatch can be refused loudly.
|
||||
pub const version: u16 = 1;
|
||||
const std = @import("std");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
pub const Operation = enum(u8) {
|
||||
/// Subscribe to power events: the subscriber's endpoint rides as the
|
||||
/// call's capability (the input/device-manager pattern); events arrive on
|
||||
/// it as buffered messages carrying an `EventMessage`.
|
||||
subscribe = 1,
|
||||
/// Orderly shutdown's last step: enter S5. Accepted only from PID 1
|
||||
/// (init) — the process that has already run the stop sequence over
|
||||
/// everything else.
|
||||
shutdown = 2,
|
||||
/// The published event payload (never sent *to* the service).
|
||||
event = 3,
|
||||
};
|
||||
|
||||
/// What happened. The vocabulary is hardware-neutral: a lid is a lid whether
|
||||
/// ACPI or a PSCI mailbox reported it.
|
||||
pub const Event = enum(u8) {
|
||||
power_button = 1,
|
||||
lid = 2,
|
||||
ac = 3,
|
||||
battery = 4,
|
||||
/// A device notification that maps to none of the named events — the
|
||||
/// `code` and `hid` fields say which device and what code.
|
||||
notify = 5,
|
||||
};
|
||||
|
||||
pub const Subscribe = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.subscribe),
|
||||
reserved0: u8 = 0,
|
||||
version: u16 = version,
|
||||
reserved1: u32 = 0,
|
||||
};
|
||||
|
||||
pub const Shutdown = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.shutdown),
|
||||
reserved0: u8 = 0,
|
||||
version: u16 = version,
|
||||
reserved1: u32 = 0,
|
||||
};
|
||||
|
||||
/// A published event, as the buffered-message payload subscribers receive.
|
||||
pub const EventMessage = extern struct {
|
||||
operation: u8 = @intFromEnum(Operation.event),
|
||||
/// An Event value.
|
||||
event: u8,
|
||||
reserved0: u16 = 0,
|
||||
/// The device notification code (Notify's second argument), or 0.
|
||||
/// What a published event carries beyond its kind. The kind is the packet's
|
||||
/// operation, so nothing here repeats it; `power_button`, `lid`, `ac` and
|
||||
/// `battery` leave both fields zero and are fully described by the verb alone.
|
||||
pub const Notice = extern struct {
|
||||
/// The device notification code (ACPI `Notify`'s second argument), or 0.
|
||||
code: u32 = 0,
|
||||
/// The notifying device's hardware id (EISA-decoded), or all zero.
|
||||
hid: [8]u8 = .{0} ** 8,
|
||||
};
|
||||
|
||||
pub const Reply = extern struct {
|
||||
status: i32,
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "power",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
// Orderly shutdown's last step: enter S5. Honored only from a
|
||||
// subscriber — init, the process that has already run the stop sequence
|
||||
// over everything else (docs/os-development/power.md, "authority, not
|
||||
// information"). Nothing to say and nothing to answer, so the verb and
|
||||
// the reply's `Status` are the whole exchange.
|
||||
.{ .name = "shutdown" },
|
||||
},
|
||||
.events = &.{
|
||||
// The vocabulary is hardware-neutral: a lid is a lid whether ACPI or a
|
||||
// PSCI mailbox reported it. One event per kind, each carrying the same
|
||||
// `Notice`, because what differs between them is which thing happened —
|
||||
// and that is the header's job now.
|
||||
.{ .name = "power_button", .payload = Notice },
|
||||
.{ .name = "lid", .payload = Notice },
|
||||
.{ .name = "ac", .payload = Notice },
|
||||
.{ .name = "battery", .payload = Notice },
|
||||
// A device notification that maps to none of the named events — the
|
||||
// `code` and `hid` say which device and what happened.
|
||||
.{ .name = "notify", .payload = Notice },
|
||||
},
|
||||
});
|
||||
|
||||
/// Upper bound on any message in this protocol — sizes endpoint buffers.
|
||||
pub const message_maximum = 64;
|
||||
pub const Operation = Protocol.Operation;
|
||||
|
||||
/// What happened. The event *is* the kind: this is the generated event
|
||||
/// enumeration, re-exported under the name this protocol has always called its
|
||||
/// vocabulary, with the same members it has always had.
|
||||
pub const Event = Protocol.Event;
|
||||
|
||||
/// What a provider and a subscriber size their buffers to. This module used to
|
||||
/// declare 64 — the *push* floor — which was simply wrong for a protocol whose
|
||||
/// requests ride `ipc_call`: a provider sizing its receive buffer to 64 refuses
|
||||
/// any caller that sends up to the floor it is entitled to.
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
test "the kind is the verb, and an event fits the push floor" {
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.shutdown));
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.power_button));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Event.lid));
|
||||
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Event.ac));
|
||||
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Event.battery));
|
||||
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Event.notify));
|
||||
// subscribe is the RESERVED verb, below the protocol range entirely.
|
||||
try std.testing.expectEqual(@as(u32, 2), envelope.operation_subscribe);
|
||||
try std.testing.expectEqual(envelope.prefix_size + @sizeOf(Notice), Protocol.event_maximum);
|
||||
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
|
||||
// The call floor, not the push floor: `shutdown` is a synchronous call.
|
||||
try std.testing.expectEqual(envelope.packet_maximum, message_maximum);
|
||||
}
|
||||
|
||||
test "a pushed event names its kind in the header" {
|
||||
var buffer: [envelope.post_maximum]u8 = undefined;
|
||||
const packet = Protocol.encodeEvent(.power_button, 0, .{}, &buffer).?;
|
||||
try std.testing.expectEqual(Event.power_button, Protocol.eventOf(packet).?);
|
||||
|
||||
const notified = Protocol.encodeEvent(.notify, 0, .{ .code = 0x80, .hid = "PNP0C0A\x00".* }, &buffer).?;
|
||||
try std.testing.expectEqual(Event.notify, Protocol.eventOf(notified).?);
|
||||
try std.testing.expectEqual(@as(u32, 0x80), Protocol.decodeEvent(.notify, notified).?.code);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,55 @@
|
||||
//! The scanout wire protocol — what the compositor says to a native scanout driver (e.g.
|
||||
//! virtio-gpu) over its well-known `.scanout` endpoint to put a composited frame on screen.
|
||||
//! The driver owns the panel and the shared scanout surface it handed the compositor (via the
|
||||
//! display service's `attach_scanout`); the compositor composites into that surface, then asks
|
||||
//! the driver to present a damaged rectangle. Tiny by design — one present request. Separate
|
||||
//! from the display protocol because the directions differ: clients call the compositor over
|
||||
//! `.display`; the compositor calls the driver over `.scanout`. See docs/display-v2.md.
|
||||
//! virtio-gpu) over `/protocol/scanout` to put a composited frame on screen. The driver owns
|
||||
//! the panel and the shared scanout surface it handed the compositor (via the display
|
||||
//! service's `attach_scanout`); the compositor composites into that surface, then asks the
|
||||
//! driver to present a damaged rectangle. Tiny by design — one present request. Separate from
|
||||
//! the display protocol because the directions differ: clients call the compositor over
|
||||
//! `/protocol/display`; the compositor calls the driver over `/protocol/scanout`. See
|
||||
//! docs/display-v2.md.
|
||||
//!
|
||||
//! One scanout per driver instance, so `Header.target` is always 0.
|
||||
|
||||
const std = @import("std");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
/// present(x, y, width, height): put the given rectangle of the shared scanout surface on
|
||||
/// the panel (on virtio-gpu: transfer-to-host of the region, then a fenced resource flush).
|
||||
present = 0,
|
||||
/// get_modes() -> ModesReply: the display modes this scanout can switch to (V5).
|
||||
get_modes = 1,
|
||||
/// set_mode(width, height): change the scanout resolution — the shared surface is sized to
|
||||
/// the largest mode, so this just re-points the scanout rectangle; the surface is unchanged.
|
||||
set_mode = 2,
|
||||
};
|
||||
|
||||
pub const Request = extern struct {
|
||||
operation: u32,
|
||||
/// `present(rect)`: put the given rectangle of the shared scanout surface on the panel (on
|
||||
/// virtio-gpu: transfer-to-host of the region, then a fenced resource flush).
|
||||
pub const Present = extern struct {
|
||||
x: u32 = 0,
|
||||
y: u32 = 0,
|
||||
width: u32 = 0,
|
||||
height: u32 = 0,
|
||||
};
|
||||
|
||||
pub const Reply = extern struct {
|
||||
status: i32, // 0 on success, negative on failure
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
/// `set_mode(width, height)`: change the scanout resolution — the shared surface is sized to
|
||||
/// the largest mode, so this just re-points the scanout rectangle; the surface is unchanged.
|
||||
pub const SetMode = extern struct { width: u32, height: u32 };
|
||||
|
||||
/// One offered display mode.
|
||||
pub const Mode = extern struct { width: u32, height: u32 };
|
||||
pub const max_modes = 4;
|
||||
|
||||
/// The reply to `get_modes`: a small fixed list of modes.
|
||||
pub const ModesReply = extern struct {
|
||||
status: i32,
|
||||
count: u32,
|
||||
modes: [max_modes]Mode,
|
||||
/// The answer to `get_modes`: a small fixed list of modes. The success/failure verdict is
|
||||
/// the reply's `Status`, so this carries only the modes.
|
||||
pub const Modes = extern struct {
|
||||
count: u32 = 0,
|
||||
_padding: u32 = 0,
|
||||
modes: [max_modes]Mode = @splat(.{ .width = 0, .height = 0 }),
|
||||
};
|
||||
|
||||
pub const message_maximum: usize = 64;
|
||||
pub const request_size: usize = @sizeOf(Request);
|
||||
pub const reply_size: usize = @sizeOf(Reply);
|
||||
pub const modes_reply_size: usize = @sizeOf(ModesReply);
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "scanout",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "present", .request = Present },
|
||||
.{ .name = "get_modes", .reply = Modes },
|
||||
.{ .name = "set_mode", .request = SetMode },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
|
||||
/// The call floor, like every synchronous protocol. This module used to declare
|
||||
/// 64 — the *push* floor — which was simply wrong: nothing here is pushed, and a
|
||||
/// provider sizing its receive buffer to 64 refuses (`-E2BIG`) any caller that
|
||||
/// sends up to the floor it is entitled to.
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
@@ -1,57 +1,66 @@
|
||||
//! The USB transfer protocol: what a USB class driver (a keyboard, mouse, or
|
||||
//! mass-storage driver) says to the xHCI bus driver over its well-known
|
||||
//! `.usb_bus` endpoint to drive its device. The class driver owns no hardware —
|
||||
//! it reaches its device entirely through these messages, the way a PS/2 keyboard
|
||||
//! driver reaches the 8042 through the ps2-bus. Extern-struct messages tagged by
|
||||
//! `Operation`, the vfs-protocol / device-manager-protocol pattern.
|
||||
//! mass-storage driver) says to the xHCI bus driver over `/protocol/usb-transfer`
|
||||
//! to drive its device. The class driver owns no hardware — it reaches its device
|
||||
//! entirely through these packets, the way a PS/2 keyboard driver reaches the
|
||||
//! 8042 through the ps2-bus.
|
||||
//!
|
||||
//! Defined through the envelope (docs/os-development/protocol-namespace.md), so
|
||||
//! every packet begins with the folded `Header`. **`Header.target` is the device
|
||||
//! token** — the per-open handle the bus driver hands back, which every request
|
||||
//! but `open` addressed through a `device_token` field of its own before the
|
||||
//! rebase. `open` itself addresses the *assigned device id*, because that is what
|
||||
//! the caller has before there is a token.
|
||||
//!
|
||||
//! The shape:
|
||||
//! - **open** (a capability-passing `ipc.callCap`): the class driver hands over
|
||||
//! its own endpoint (for asynchronous interrupt reports) and its assigned
|
||||
//! device id, and receives a `device_token` plus its interface's endpoints.
|
||||
//! - **control / bulk** (synchronous `ipc.call`): one transfer, answered when
|
||||
//! it completes. Control data travels inline (descriptors, HID/MSC class
|
||||
//! requests are all small); bulk data travels by **physical address** — the
|
||||
//! class driver's own `dma_alloc`'d buffer — so a 512-byte sector never has
|
||||
//! to cross the 256-byte IPC boundary.
|
||||
//! - **open** (a capability-passing call): the class driver hands over its own
|
||||
//! endpoint (for asynchronous interrupt reports); the target is its assigned
|
||||
//! device id, and the reply carries a `device_token` plus its interface's
|
||||
//! endpoints.
|
||||
//! - **control / bulk** (synchronous calls): one transfer, answered when it
|
||||
//! completes. Control data travels **in the packet's tail** in both
|
||||
//! directions (descriptors, HID/MSC class requests are all small), so the
|
||||
//! fixed parts stay tiny and `Status.len` is the transferred length — the
|
||||
//! envelope's own field for "how many bytes follow", which is precisely what
|
||||
//! the old `actual_length` said. Bulk data travels by **physical address** —
|
||||
//! the class driver's own `dma_alloc`'d buffer — so a 512-byte sector never
|
||||
//! has to cross the packet floor.
|
||||
//! - **interrupt_subscribe** (synchronous): arm periodic IN polling of an
|
||||
//! interrupt endpoint; each report the device produces is then pushed to the
|
||||
//! class driver's endpoint as an asynchronous `InterruptReport` (`ipc.send`),
|
||||
//! exactly how the input service delivers events.
|
||||
//! class driver's endpoint as an asynchronous `interrupt_report` event.
|
||||
//! It stays one of **this protocol's own verbs**, not the reserved
|
||||
//! `subscribe`: the reserved verb means "push me this provider's events" and
|
||||
//! carries the subscriber's endpoint, while this names one endpoint address
|
||||
//! on one device and a poll length, and the endpoint it pushes to was handed
|
||||
//! over at `open`. Same word, different contract.
|
||||
//! - **dma_attach**: a class driver hands the controller a DMA-region
|
||||
//! capability (riding the call's cap slot) so the controller binds that
|
||||
//! buffer into its IOMMU domain and may then DMA to the physical addresses
|
||||
//! inside it. Needed once per buffer the class driver will name in a `bulk`
|
||||
//! transfer (its own, or one forwarded to it).
|
||||
//!
|
||||
//! Single controller assumption: one `.usb_bus` singleton serves QEMU's one xHCI.
|
||||
//! A multi-controller machine would need a per-controller endpoint (the device
|
||||
//! manager handing each class driver the right one); noted, not built.
|
||||
//! Single controller assumption: one provider serves QEMU's one xHCI. A
|
||||
//! multi-controller machine would need the controller in the target (or the
|
||||
//! spawner wiring each class driver its own channel); noted, not built.
|
||||
|
||||
/// Fits one synchronous IPC message (kernel MESSAGE_MAXIMUM).
|
||||
pub const message_maximum: usize = 256;
|
||||
const std = @import("std");
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// The largest inline control-transfer payload. Sized so a whole message
|
||||
/// (header + data) stays under `message_maximum`: descriptors and HID/MSC class
|
||||
/// requests are all far smaller.
|
||||
pub const max_inline_data: usize = 200;
|
||||
/// The largest control-transfer data stage. It rides the packet's tail, so the
|
||||
/// bound is the call floor less the header and the fixed request part — derived
|
||||
/// rather than declared, which is what keeps it honest when a field moves.
|
||||
pub const max_inline_data: usize = envelope.packet_maximum - envelope.prefix_size - @sizeOf(Control);
|
||||
|
||||
/// The largest interrupt report pushed asynchronously. Sized so `InterruptReport`
|
||||
/// fits an `ipc_send` payload slot (POST_MAXIMUM = 64): boot keyboard reports are
|
||||
/// 8 bytes, boot mouse reports 3–4.
|
||||
pub const max_report_data: usize = 48;
|
||||
/// The largest interrupt report pushed asynchronously. An event packet is the
|
||||
/// header plus the payload within 64 bytes, so this is what is left after the
|
||||
/// report's own four bytes of framing: boot keyboard reports are 8 bytes, boot
|
||||
/// mouse reports 3–4, and the whole HID boot vocabulary fits many times over.
|
||||
/// A device that produces more has its report truncated, never split.
|
||||
pub const max_report_data: usize = 40;
|
||||
|
||||
/// Endpoints per interface reported back in an open reply (a boot HID interface
|
||||
/// has one interrupt endpoint, a mass-storage interface two bulk endpoints).
|
||||
pub const max_reported_endpoints: usize = 4;
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
open = 0,
|
||||
control = 1,
|
||||
interrupt_subscribe = 2,
|
||||
bulk = 3,
|
||||
/// dma_attach: a class driver hands the controller a DMA-region capability (riding
|
||||
/// the call's cap slot) so the controller binds that buffer into its IOMMU domain
|
||||
/// and may then DMA to the physical addresses inside it. Needed once per buffer the
|
||||
/// class driver will name in a `bulk` transfer (its own, or one forwarded to it).
|
||||
dma_attach = 4,
|
||||
};
|
||||
|
||||
/// The endpoint facts a class driver needs, lifted from the endpoint descriptor
|
||||
/// the bus driver already parsed during enumeration.
|
||||
pub const Endpoint = extern struct {
|
||||
@@ -64,114 +73,146 @@ pub const Endpoint = extern struct {
|
||||
reserved: [3]u8 = .{ 0, 0, 0 },
|
||||
};
|
||||
|
||||
/// open: the class driver's receive endpoint rides as the call's capability, and
|
||||
/// `device_id` is the interface's assigned id (its argv[1]).
|
||||
pub const OpenRequest = extern struct {
|
||||
operation: u32 = @intFromEnum(Operation.open),
|
||||
reserved: u32 = 0,
|
||||
device_id: u64,
|
||||
};
|
||||
// --- the per-operation request and reply parts ------------------------------
|
||||
//
|
||||
// Each names the bytes AFTER the prefix. Nothing here carries an operation or a
|
||||
// device token: those are the packet header's, folded in once. No reply carries
|
||||
// a status either — that is the `Status` every reply begins with.
|
||||
|
||||
/// The answer to open: a token scoping every later request to this device, the
|
||||
/// interface's class triple (a sanity check), and its endpoints.
|
||||
pub const OpenReply = extern struct {
|
||||
status: i32,
|
||||
endpoint_count: u32,
|
||||
/// The answer to `open`: the token every later packet puts in `Header.target`,
|
||||
/// the interface's class triple (a sanity check), and its endpoints.
|
||||
pub const Opened = extern struct {
|
||||
device_token: u64,
|
||||
endpoint_count: u32,
|
||||
interface_class: u8,
|
||||
interface_subclass: u8,
|
||||
interface_protocol: u8,
|
||||
interface_number: u8,
|
||||
reserved2: u32 = 0,
|
||||
endpoints: [max_reported_endpoints]Endpoint = [_]Endpoint{.{ .address = 0, .transfer_type = 0, .max_packet_size = 0, .interval = 0 }} ** max_reported_endpoints,
|
||||
};
|
||||
|
||||
/// control: one EP0 control transfer. `setup` is a bit-cast `usb_abi.Request`.
|
||||
/// For an OUT transfer `data[0..data_length]` is sent; for an IN transfer the
|
||||
/// reply carries up to `data_length` bytes back.
|
||||
pub const ControlRequest = extern struct {
|
||||
operation: u32 = @intFromEnum(Operation.control),
|
||||
reserved: u32 = 0,
|
||||
device_token: u64,
|
||||
/// `control`: one EP0 control transfer on `Header.target`. `setup` is a bit-cast
|
||||
/// `usb_abi.Request`. For an OUT transfer the data stage is the request's tail;
|
||||
/// for an IN transfer it comes back as the reply's tail, and `Status.len` is how
|
||||
/// much of it arrived.
|
||||
pub const Control = extern struct {
|
||||
setup: [8]u8,
|
||||
direction_in: u8, // 1 = device-to-host (IN), 0 = host-to-device (OUT)
|
||||
reserved2: u8 = 0,
|
||||
/// 1 = device-to-host (IN), 0 = host-to-device (OUT).
|
||||
direction_in: u8,
|
||||
_padding: u8 = 0,
|
||||
/// Bytes of data stage: what an IN transfer asks for, and what an OUT
|
||||
/// transfer's tail carries.
|
||||
data_length: u16,
|
||||
reserved3: u32 = 0,
|
||||
data: [max_inline_data]u8 = [_]u8{0} ** max_inline_data,
|
||||
_padding2: u32 = 0,
|
||||
};
|
||||
|
||||
pub const ControlReply = extern struct {
|
||||
status: i32, // 0 success, negative on failure/stall
|
||||
actual_length: u32,
|
||||
data: [max_inline_data]u8 = [_]u8{0} ** max_inline_data,
|
||||
};
|
||||
|
||||
/// interrupt_subscribe: begin periodic IN polling of an interrupt endpoint. Each
|
||||
/// report the device returns is pushed to the caller's endpoint (handed over at
|
||||
/// open) as an asynchronous `InterruptReport`.
|
||||
pub const InterruptSubscribeRequest = extern struct {
|
||||
operation: u32 = @intFromEnum(Operation.interrupt_subscribe),
|
||||
reserved: u32 = 0,
|
||||
device_token: u64,
|
||||
/// `interrupt_subscribe`: begin periodic IN polling of an interrupt endpoint of
|
||||
/// `Header.target`. Each report the device returns is pushed to the endpoint the
|
||||
/// caller handed over at `open`, as an `interrupt_report` event.
|
||||
pub const InterruptSubscribe = extern struct {
|
||||
endpoint_address: u8,
|
||||
reserved2: u8 = 0,
|
||||
max_length: u16, // bytes to request per poll (the endpoint's max packet size)
|
||||
_padding: u8 = 0,
|
||||
/// Bytes to request per poll (the endpoint's max packet size).
|
||||
max_length: u16,
|
||||
};
|
||||
|
||||
pub const InterruptSubscribeReply = extern struct {
|
||||
status: i32,
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
|
||||
/// bulk: one bulk IN or OUT transfer. `physical_address` is the class driver's own
|
||||
/// `dma_alloc`'d buffer — the controller DMAs straight to/from it, so the bulk
|
||||
/// data never crosses IPC. `endpoint_address`'s bit 7 selects IN vs OUT.
|
||||
pub const BulkRequest = extern struct {
|
||||
operation: u32 = @intFromEnum(Operation.bulk),
|
||||
reserved: u32 = 0,
|
||||
device_token: u64,
|
||||
/// `bulk`: one bulk IN or OUT transfer on `Header.target`. `physical_address` is
|
||||
/// the class driver's own `dma_alloc`'d buffer — the controller DMAs straight
|
||||
/// to/from it, so the bulk data never crosses IPC. `endpoint_address`'s bit 7
|
||||
/// selects IN vs OUT.
|
||||
pub const Bulk = extern struct {
|
||||
physical_address: u64,
|
||||
length: u32,
|
||||
endpoint_address: u8,
|
||||
reserved2: u8 = 0,
|
||||
reserved3: u16 = 0,
|
||||
_padding: u8 = 0,
|
||||
_padding2: u16 = 0,
|
||||
};
|
||||
|
||||
pub const BulkReply = extern struct {
|
||||
status: i32,
|
||||
actual_length: u32,
|
||||
};
|
||||
/// How many bytes a bulk transfer actually moved. It cannot ride `Status.len`
|
||||
/// the way a control transfer's does: nothing follows a bulk reply, because the
|
||||
/// data went to the caller's DMA buffer rather than into the packet.
|
||||
pub const Transferred = extern struct { actual_length: u32 };
|
||||
|
||||
/// dma_attach: the region capability rides the call's cap slot; the body only carries
|
||||
/// the device token (scoping) so the controller knows which caller is attaching.
|
||||
pub const DmaAttachRequest = extern struct {
|
||||
operation: u32 = @intFromEnum(Operation.dma_attach),
|
||||
reserved: u32 = 0,
|
||||
device_token: u64,
|
||||
};
|
||||
|
||||
pub const DmaAttachReply = extern struct {
|
||||
status: i32,
|
||||
reserved: u32 = 0,
|
||||
};
|
||||
|
||||
/// An asynchronous interrupt report, pushed with `ipc.send` to a subscriber's
|
||||
/// endpoint. `Received.isMessage()` is set; there is no reply owed.
|
||||
/// One asynchronous interrupt report, pushed to the endpoint the class driver
|
||||
/// handed over at `open`. The device it came from is `Header.target`.
|
||||
pub const InterruptReport = extern struct {
|
||||
device_token: u64,
|
||||
endpoint_address: u8,
|
||||
length: u8,
|
||||
reserved: u16 = 0,
|
||||
_padding: u16 = 0,
|
||||
data: [max_report_data]u8 = [_]u8{0} ** max_report_data,
|
||||
};
|
||||
|
||||
comptime {
|
||||
const std = @import("std");
|
||||
// Every synchronous message must fit one IPC message; the async report must
|
||||
// fit an ipc_send payload slot.
|
||||
std.debug.assert(@sizeOf(ControlRequest) <= message_maximum);
|
||||
std.debug.assert(@sizeOf(ControlReply) <= message_maximum);
|
||||
std.debug.assert(@sizeOf(OpenReply) <= message_maximum);
|
||||
std.debug.assert(@sizeOf(InterruptReport) <= 64);
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "usb-transfer",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
// open: the target is the interface's assigned device id (its argv[1]),
|
||||
// and the class driver's receive endpoint rides as the capability.
|
||||
.{ .name = "open", .reply = Opened },
|
||||
.{ .name = "control", .request = Control },
|
||||
.{ .name = "interrupt_subscribe", .request = InterruptSubscribe },
|
||||
.{ .name = "bulk", .request = Bulk, .reply = Transferred },
|
||||
// dma_attach: the region capability rides the call's cap slot; the
|
||||
// target says which caller's device is attaching, so there is nothing
|
||||
// left for a body to carry.
|
||||
.{ .name = "dma_attach" },
|
||||
},
|
||||
.events = &.{
|
||||
.{ .name = "interrupt_report", .payload = InterruptReport },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
pub const Event = Protocol.Event;
|
||||
|
||||
/// What both sides size their buffers to — the call floor, as every protocol does.
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
test "the budgets, re-verified by Define rather than by hand" {
|
||||
// What the hand-rolled comptime asserts used to say, now said by `Define`
|
||||
// — and counting the header, which the old checks did not.
|
||||
try std.testing.expectEqual(@as(usize, 224), max_inline_data);
|
||||
try std.testing.expect(Protocol.request_maximum <= envelope.packet_maximum);
|
||||
try std.testing.expect(Protocol.reply_maximum <= envelope.packet_maximum);
|
||||
// The report was 48 bytes of data in a 64-byte struct that had no room left
|
||||
// for a header. Folding the device token into the target and trimming the
|
||||
// data to 40 leaves the whole packet at 60 of the 64-byte push floor.
|
||||
try std.testing.expectEqual(@as(usize, 44), @sizeOf(InterruptReport));
|
||||
try std.testing.expectEqual(@as(usize, 60), Protocol.event_maximum);
|
||||
try std.testing.expect(Protocol.event_maximum <= envelope.post_maximum);
|
||||
}
|
||||
|
||||
test "the verb numbering, and the device token in the header" {
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.open));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.control));
|
||||
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.interrupt_subscribe));
|
||||
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Operation.bulk));
|
||||
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Operation.dma_attach));
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Event.interrupt_report));
|
||||
|
||||
var buffer: [message_maximum]u8 = undefined;
|
||||
const packet = Protocol.encodeRequest(.control, 9, .{
|
||||
.setup = .{ 0, 6, 0, 1, 0, 0, 18, 0 },
|
||||
.direction_in = 1,
|
||||
.data_length = 18,
|
||||
}, &.{}, &buffer).?;
|
||||
try std.testing.expectEqual(@as(u64, 9), envelope.headerOf(packet).?.target);
|
||||
try std.testing.expectEqual(@as(u16, 18), Protocol.decodeRequest(.control, packet).?.data_length);
|
||||
}
|
||||
|
||||
test "a control OUT carries its data stage as the packet's tail" {
|
||||
var buffer: [message_maximum]u8 = undefined;
|
||||
const payload = [_]u8{ 1, 2, 3, 4 };
|
||||
const packet = Protocol.encodeRequest(.control, 5, .{
|
||||
.setup = .{ 0x21, 11, 0, 0, 0, 0, 4, 0 },
|
||||
.direction_in = 0,
|
||||
.data_length = payload.len,
|
||||
}, &payload, &buffer).?;
|
||||
try std.testing.expectEqualSlices(u8, &payload, Protocol.requestTail(.control, packet));
|
||||
|
||||
// And the answer to an IN: the bytes follow the (empty) fixed reply part,
|
||||
// with `Status.len` counting exactly them.
|
||||
const answered = Protocol.encodeReply(.control, 0, {}, &payload, &buffer).?;
|
||||
try std.testing.expectEqual(@as(u32, payload.len), envelope.statusOf(answered).?.len);
|
||||
try std.testing.expectEqualSlices(u8, &payload, Protocol.replyTail(.control, answered));
|
||||
}
|
||||
|
||||
@@ -1,40 +1,30 @@
|
||||
//! The VFS wire protocol — the message format spoken between a client (via the file
|
||||
//! API) and the user-space VFS server over IPC. A request is a fixed `Request` header
|
||||
//! followed by an inline payload (a path, or write bytes); a reply is a fixed `Reply`
|
||||
//! header followed by an inline payload (read bytes, or a FileStatus). Everything fits
|
||||
//! in one IPC message (<= ipc MESSAGE_MAXIMUM = 256 bytes).
|
||||
//! The VFS wire protocol — what a client (through the file API,
|
||||
//! library/kernel/file-system.zig) says to a filesystem backend over IPC. Defined
|
||||
//! through the envelope (docs/os-development/protocol-namespace.md), so every
|
||||
//! packet begins with the folded `Header`: the verb in `Header.operation`, and
|
||||
//! **the open node id in `Header.target`** — the field that used to be
|
||||
//! `Request.node`. A path appears in the conversation once, at `open`; every
|
||||
//! packet after it addresses that integer.
|
||||
//!
|
||||
//! This is a danos-native contract, so it uses danos names throughout. The client
|
||||
//! side is `runtime.fs` (library/runtime/fs.zig), which programs use directly.
|
||||
//! This is a danos-native contract, so it uses danos names throughout. It is
|
||||
//! user-space only — the kernel knows nothing of files or paths; it only routes
|
||||
//! (`fs_resolve`) and moves the bytes. The backends that serve it today are the
|
||||
//! FAT server (system/services/fat/) and the protocol registry inside PID 1
|
||||
//! (system/services/init/), which is a *synthetic* backend: `/protocol` holds
|
||||
//! contracts rather than files.
|
||||
//!
|
||||
//! This is user-space only — the kernel knows nothing of files or paths; it only moves the bytes.
|
||||
//! Shared by library/runtime/fs.zig (the client) and the mount backends that serve it (today
|
||||
//! the fat server, system/services/fat/). The standalone user-space VFS server it was first
|
||||
//! 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 `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 the registry does: opening a
|
||||
//! `NodeKind.protocol` node under `/protocol` returns the provider's endpoint,
|
||||
//! which is the channel. The convention is per-backend, not per-operation: a
|
||||
//! client that did not ask a synthetic backend simply gets no capability back.
|
||||
|
||||
pub const Operation = enum(u32) {
|
||||
open, // open(path) -> node id
|
||||
close, // close(node)
|
||||
read, // read(node, offset, len) -> bytes
|
||||
write, // write(node, offset, bytes) -> count
|
||||
status, // status(node) -> FileStatus
|
||||
// Appended for the mount router (M5). Values stay stable, so existing clients
|
||||
// and the flat-ramfs tests are unaffected.
|
||||
readdir, // readdir(dir_node, cursor=offset) -> one DirectoryEntry (len==0 => EOF)
|
||||
mount, // mount(prefix payload, capability = backend endpoint)
|
||||
unmount, // unmount(prefix payload)
|
||||
// Appended for filesystem mutation (Phase 2). Path-based (the path is the
|
||||
// payload); a mounted backend handles them, the flat ramfs refuses them.
|
||||
mkdir, // mkdir(path payload) -> status
|
||||
unlink, // unlink(path payload) -> status
|
||||
// rename: the payload is the old path, a single 0x00 separator, then the new
|
||||
// path. Same-directory rename only (the router requires both under one mount).
|
||||
rename, // rename(old\0new payload) -> status
|
||||
};
|
||||
const envelope = @import("envelope");
|
||||
|
||||
/// The type of a filesystem node, aligned to the FSH file-type table
|
||||
/// (docs/danos-file-system-hierarchy-FSH.md). Fills `FileStatus.kind` and
|
||||
/// The type of a filesystem node, aligned to the node-kind table
|
||||
/// (docs/file-system-development/file-system-hierarchy.md). Fills `FileStatus.kind` and
|
||||
/// `DirectoryEntry.kind`; `regular = 0` keeps the historical hardcoded value.
|
||||
pub const NodeKind = enum(u32) {
|
||||
regular = 0,
|
||||
@@ -44,40 +34,26 @@ 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
|
||||
/// the reply payload by `name_len` bytes of name. A zero-length reply is EOF.
|
||||
/// One directory entry: the fixed part of a `readdir` reply, followed inline by
|
||||
/// `name_len` bytes of name. **A zero `name_len` is end of directory** — the
|
||||
/// reply's own length cannot say so any more, because the envelope always sends
|
||||
/// the fixed part.
|
||||
pub const DirectoryEntry = extern struct {
|
||||
kind: u32, // a NodeKind
|
||||
name_len: u32,
|
||||
size: u64,
|
||||
kind: u32 = 0, // a NodeKind
|
||||
name_len: u32 = 0,
|
||||
size: u64 = 0,
|
||||
};
|
||||
|
||||
pub const directory_entry_size: usize = @sizeOf(DirectoryEntry);
|
||||
|
||||
/// Request header. `node` is the server-side open-file id (from a prior open);
|
||||
/// for `open` the path is the payload and `len` is its length. `offset`/`len`
|
||||
/// carry the read/write position and count.
|
||||
pub const Request = extern struct {
|
||||
operation: Operation,
|
||||
node: u64,
|
||||
offset: u64,
|
||||
len: u32,
|
||||
flags: u32,
|
||||
};
|
||||
|
||||
/// Reply header. `status` is 0 on success or a negative errno; `node` is the new
|
||||
/// open-file id (for `open`); `len` is the payload length (bytes read, or the
|
||||
/// FileStatus size).
|
||||
pub const Reply = extern struct {
|
||||
status: i32,
|
||||
_padding: u32 = 0,
|
||||
node: u64 = 0,
|
||||
len: u32 = 0,
|
||||
_padding2: u32 = 0,
|
||||
};
|
||||
|
||||
/// A file's metadata (the danos-native answer to a `status` request). The POSIX
|
||||
/// layer maps this onto `struct stat`.
|
||||
pub const FileStatus = extern struct {
|
||||
@@ -89,13 +65,84 @@ pub const FileStatus = extern struct {
|
||||
mtime: u64 = 0,
|
||||
};
|
||||
|
||||
pub const message_maximum: usize = 256;
|
||||
pub const request_size: usize = @sizeOf(Request);
|
||||
pub const reply_size: usize = @sizeOf(Reply);
|
||||
/// Largest inline payload that still fits one IPC message alongside a header.
|
||||
pub const maximum_payload: usize = message_maximum - request_size;
|
||||
// --- the per-operation request and reply parts ------------------------------
|
||||
//
|
||||
// Each names the bytes AFTER the prefix. Nothing here carries an operation or a
|
||||
// node id: those are the packet header's, folded in once.
|
||||
|
||||
/// Open flags (danos-native; `runtime.fs.OpenOptions` maps its booleans onto these).
|
||||
/// `open(flags)` with the path as the packet's tail. The one verb that spends a
|
||||
/// path; everything after it addresses the node id this returns.
|
||||
pub const Open = extern struct { flags: u32 = 0 };
|
||||
|
||||
/// The node id an `open` established — the integer every later packet puts in
|
||||
/// `Header.target`. Meaningful only between this client and this backend.
|
||||
pub const Opened = extern struct { node: u64 };
|
||||
|
||||
/// `read(offset, len)` on `Header.target`; the bytes come back as the reply tail.
|
||||
pub const Read = extern struct {
|
||||
offset: u64,
|
||||
len: u32,
|
||||
_padding: u32 = 0,
|
||||
};
|
||||
|
||||
/// `write(offset, len)` on `Header.target`, with the data as the packet's tail.
|
||||
pub const Write = extern struct {
|
||||
offset: u64,
|
||||
len: u32,
|
||||
_padding: u32 = 0,
|
||||
};
|
||||
|
||||
/// How many bytes a `write` actually took — it may be short.
|
||||
pub const Written = extern struct { count: u32 };
|
||||
|
||||
/// `readdir(cursor)` on `Header.target`: one entry per call, cursor-advanced.
|
||||
pub const Readdir = extern struct { cursor: u64 };
|
||||
|
||||
/// The contract, whole. Verbs number from `envelope.first_protocol_operation`
|
||||
/// (16) in this order; the reserved verbs below it mean what they mean
|
||||
/// everywhere. `readdir` stays a protocol verb rather than folding into the
|
||||
/// reserved `enumerate`: it enumerates the children of one *node*, where
|
||||
/// `enumerate` names a provider's targets.
|
||||
pub const Protocol = envelope.Define(.{
|
||||
.name = "vfs",
|
||||
.version = 1,
|
||||
.operations = &.{
|
||||
.{ .name = "open", .request = Open, .reply = Opened },
|
||||
.{ .name = "close" },
|
||||
.{ .name = "read", .request = Read },
|
||||
.{ .name = "write", .request = Write, .reply = Written },
|
||||
.{ .name = "status", .reply = FileStatus },
|
||||
.{ .name = "readdir", .request = Readdir, .reply = DirectoryEntry },
|
||||
// The mount router's two verbs. Path routing lives in the kernel now
|
||||
// (system/kernel/vfs.zig), so no backend implements either; they keep
|
||||
// their numbers so the vocabulary stays the one docs/vfs-protocol.md
|
||||
// describes.
|
||||
.{ .name = "mount" }, // tail = the prefix, capability = the backend's endpoint
|
||||
.{ .name = "unmount" }, // tail = the prefix
|
||||
// Filesystem mutation, path-based: the path is the packet's tail.
|
||||
.{ .name = "mkdir" },
|
||||
.{ .name = "unlink" },
|
||||
// rename: the tail is the old path, a single 0x00 separator, then the
|
||||
// new path. Same-directory rename only.
|
||||
.{ .name = "rename" },
|
||||
// The registry's claim verb (P2): the name is the tail and the
|
||||
// provider's endpoint rides the call as its capability. A file backend
|
||||
// refuses it; only init implements it.
|
||||
.{ .name = "bind" },
|
||||
},
|
||||
});
|
||||
|
||||
pub const Operation = Protocol.Operation;
|
||||
|
||||
/// What a backend sizes its buffers to — the call floor, as every protocol does.
|
||||
pub const message_maximum: usize = Protocol.message_maximum;
|
||||
|
||||
/// The most inline payload any request may carry: the floor less the header and
|
||||
/// the widest fixed request part, so one bound serves every verb (a path, write
|
||||
/// data, a read's answer).
|
||||
pub const maximum_payload: usize = envelope.packet_maximum - Protocol.request_maximum;
|
||||
|
||||
/// Open flags (danos-native; `file_system.OpenOptions` maps its booleans onto these).
|
||||
pub const create: u32 = 1;
|
||||
/// Open a directory (for readdir) rather than a file. A mounted backend uses
|
||||
/// this to open a directory node; the flat ramfs ignores it.
|
||||
@@ -105,13 +152,42 @@ pub const directory: u32 = 2;
|
||||
/// backend frees the old cluster chain; the flat ramfs ignores it.
|
||||
pub const truncate: u32 = 4;
|
||||
|
||||
test "protocol struct sizes and node kinds" {
|
||||
test "the stable wire values: node kinds, entry layout, and the verb numbering" {
|
||||
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));
|
||||
try std.testing.expectEqual(@as(u32, 4), @intFromEnum(Operation.status));
|
||||
try std.testing.expectEqual(@as(u32, 5), @intFromEnum(Operation.readdir));
|
||||
|
||||
// The numbering the envelope gives this protocol. These are NEW values: the
|
||||
// rebase moved every verb above the reserved range, so the old 0..11 are
|
||||
// gone and 16..27 are what the wire carries. Pinned because both sides of a
|
||||
// flag-day have to agree on them, not because they may never change again.
|
||||
try std.testing.expectEqual(@as(u32, 16), @intFromEnum(Operation.open));
|
||||
try std.testing.expectEqual(@as(u32, 17), @intFromEnum(Operation.close));
|
||||
try std.testing.expectEqual(@as(u32, 18), @intFromEnum(Operation.read));
|
||||
try std.testing.expectEqual(@as(u32, 19), @intFromEnum(Operation.write));
|
||||
try std.testing.expectEqual(@as(u32, 20), @intFromEnum(Operation.status));
|
||||
try std.testing.expectEqual(@as(u32, 21), @intFromEnum(Operation.readdir));
|
||||
try std.testing.expectEqual(@as(u32, 26), @intFromEnum(Operation.rename));
|
||||
try std.testing.expectEqual(@as(u32, 27), @intFromEnum(Operation.bind));
|
||||
// The payload bound is what it always was, arrived at the other way round:
|
||||
// the header plus the widest fixed request part is 32 bytes of the floor.
|
||||
try std.testing.expectEqual(@as(usize, 224), maximum_payload);
|
||||
}
|
||||
|
||||
test "the node id rides the header, and a path rides the tail" {
|
||||
const std = @import("std");
|
||||
var buffer: [message_maximum]u8 = undefined;
|
||||
|
||||
const opening = Protocol.encodeRequest(.open, 0, .{ .flags = create }, "/a/b", &buffer).?;
|
||||
try std.testing.expectEqual(@as(u32, create), Protocol.decodeRequest(.open, opening).?.flags);
|
||||
try std.testing.expectEqualStrings("/a/b", Protocol.requestTail(.open, opening));
|
||||
try std.testing.expectEqual(@as(u64, 0), envelope.headerOf(opening).?.target);
|
||||
|
||||
const reading = Protocol.encodeRequest(.read, 7, .{ .offset = 512, .len = 64 }, &.{}, &buffer).?;
|
||||
try std.testing.expectEqual(@as(u64, 7), envelope.headerOf(reading).?.target);
|
||||
try std.testing.expectEqual(@as(u64, 512), Protocol.decodeRequest(.read, reading).?.offset);
|
||||
}
|
||||
|
||||
+18
-22
@@ -1,6 +1,6 @@
|
||||
//! The **private kernel ↔ runtime** ABI: the raw system_call contract — the call
|
||||
//! numbers, `mmap` protection flags, the page size those calls work in, and the IPC
|
||||
//! name-registry ids and notification bit. Shared by the kernel dispatcher
|
||||
//! notification bits. Shared by the kernel dispatcher
|
||||
//! (system/kernel/process.zig) and the user-space runtime library (library/runtime/),
|
||||
//! so the two can never drift.
|
||||
//!
|
||||
@@ -33,8 +33,13 @@ pub const SystemCall = enum(u64) {
|
||||
mmap = 4, // mmap(len, prot) -> base: grant zeroed, page-aligned user pages
|
||||
munmap = 5, // munmap(base, len): release pages from a prior mmap
|
||||
create_ipc_endpoint = 6, // create_ipc_endpoint() -> handle: a new IPC endpoint
|
||||
ipc_register = 7, // ipc_register(service_id, handle): publish an endpoint by well-known id
|
||||
ipc_lookup = 8, // ipc_lookup(service_id) -> handle: find a published endpoint
|
||||
// 7 and 8 were ipc_register/ipc_lookup — the flat ServiceId name registry,
|
||||
// retired with the protocol namespace (docs/os-development/protocol-namespace.md).
|
||||
// A service now binds its name at the registry (init, over /protocol) and a
|
||||
// client resolves and opens that path; neither is a system call any more. The
|
||||
// numbers stay vacant rather than being reused: every other entry is
|
||||
// position-fixed by an explicit value, so a hole costs nothing and a reused
|
||||
// number would silently mean two things across a rebuild boundary.
|
||||
ipc_call = 9, // ipc_call(h, message, len, reply, cap) -> reply_len: send + block for reply
|
||||
ipc_reply_wait = 10, // ipc_reply_wait(h, reply, len, receive, cap) -> receive_len (+badge in rdx)
|
||||
device_enumerate = 11, // device_enumerate(buffer, maximum) -> count: snapshot the device table
|
||||
@@ -247,8 +252,11 @@ pub const klog_maximum_message: usize = 256;
|
||||
pub const fs_route_kernel: u64 = 0; // rdx = node token; serve via fs_node
|
||||
pub const fs_route_backend: u64 = 1; // rdx = endpoint handle; speak vfs-protocol
|
||||
|
||||
/// fs_node operations — the same numbers as the vfs-protocol Operation enum, so
|
||||
/// client code shares one vocabulary.
|
||||
/// fs_node operations. These were once the vfs-protocol Operation numbers; the
|
||||
/// rebase onto the envelope moved every protocol verb above the reserved range
|
||||
/// (16 and up), and these did not follow — they are a *syscall* selector, not a
|
||||
/// packet's verb, and renumbering a kernel ABI to track a wire format would be
|
||||
/// coupling in the wrong direction. The two vocabularies are simply separate now.
|
||||
pub const fs_node_read: u64 = 2;
|
||||
pub const fs_node_status: u64 = 4;
|
||||
pub const fs_node_readdir: u64 = 5;
|
||||
@@ -284,23 +292,11 @@ pub const KlogStatus = extern struct {
|
||||
boot_unix_seconds: u64, // wall-clock time of boot (RTC anchor)
|
||||
};
|
||||
|
||||
/// Well-known IPC service ids for the bootstrap name registry (create_ipc_endpoint +
|
||||
/// ipc_register/ipc_lookup). Small integers, so no string interning is needed
|
||||
/// during bring-up. The VFS server registers under `vfs`; clients look it up.
|
||||
pub const ServiceId = enum(u32) {
|
||||
vfs = 1, // RETIRED: the router moved into the kernel (fs_resolve); the slot stays reserved
|
||||
input = 2,
|
||||
ps2_bus = 3, // the 8042 owner; child device drivers attach here for raw bytes
|
||||
device_manager = 4, // the tree, the matcher, the supervisor (docs/device-manager.md)
|
||||
power = 5, // system power: events (button, lid, battery) + shutdown (docs/power.md; domain-named per docs/discovery.md — the acpi service registers it on x86, a PSCI service will on ARM)
|
||||
usb_bus = 6, // the xHCI host-controller driver's transfer endpoint; USB class drivers look it up and `callCap`-open their device to get a private per-device transfer channel (docs/driver-model.md)
|
||||
block = 7, // a block-device driver (USB mass storage today): read/write of fixed-size blocks, the storage a filesystem sits on
|
||||
fat = 8, // the FAT filesystem server; the VFS mounts it and forwards paths under its mount point (/mnt/usb) to it
|
||||
display = 9, // the display service: owns the framebuffer, composites a layer stack, presents frames (docs/display.md)
|
||||
shared_memory_test = 10, // the shared-memory test server (V2): a client passes it a shared-memory capability, it maps + verifies (docs/display-v2.md)
|
||||
scanout = 11, // a native scanout driver (virtio-gpu): the compositor finds it here to upgrade off the GOP framebuffer (docs/display-v2.md)
|
||||
_,
|
||||
};
|
||||
// The `ServiceId` enum lived here: a flat, compile-time list of well-known
|
||||
// service ids backed by a 16-slot kernel table. It is gone with the protocol
|
||||
// namespace — names are strings resolved under `/protocol` at run time, so a
|
||||
// third-party program can introduce a contract the ABI never heard of, and the
|
||||
// registrar (init) decides who may claim one.
|
||||
|
||||
/// Protection flags for `mmap` (matching the usual C bit values).
|
||||
pub const prot_read: u64 = 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# /etc/devices.csv — the device→driver registry.
|
||||
# /system/configuration/devices.csv — the device→driver registry.
|
||||
#
|
||||
# The device manager reads this at boot and binds each device a bus driver
|
||||
# reports to the driver named here. It is AUTHORITATIVE: a device that no row
|
||||
|
@@ -1,9 +1,10 @@
|
||||
# /etc/init.csv — diagnose variant (-Ddiagnose), bundled at /etc/init.csv.
|
||||
# /system/configuration/init.csv — diagnose variant (-Ddiagnose), bundled at
|
||||
# /system/configuration/init.csv.
|
||||
#
|
||||
# The display stack (display, display-demo) is omitted so the kernel's timestamped
|
||||
# on-screen boot transcript is never suppressed — the bring-up timeline (USB,
|
||||
# storage, logger) stays readable on real hardware with no serial. See etc/init.csv
|
||||
# for the format; this file must otherwise track it.
|
||||
# storage, logger) stays readable on real hardware with no serial. See
|
||||
# system/configuration/init.csv for the format; this file must otherwise track it.
|
||||
#
|
||||
# service args...
|
||||
/system/services/input
|
||||
|
@@ -1,4 +1,4 @@
|
||||
# /etc/init.csv — the services init (PID 1) starts at boot, in order.
|
||||
# /system/configuration/init.csv — the services init (PID 1) starts at boot, in order.
|
||||
#
|
||||
# init reads this at startup and spawns each service supervised (restarting it on
|
||||
# a crash, up to a cap). Startup order is top->bottom; shutdown is the reverse, so
|
||||
@@ -9,7 +9,7 @@
|
||||
# '#' starts a comment (whole-line or trailing); blank lines are ignored. The
|
||||
# first field is the service binary path; any fields after it are the service's
|
||||
# argv. Drivers are absent on purpose — the device manager discovers hardware and
|
||||
# spawns those (see /etc/devices.csv).
|
||||
# spawns those (see /system/configuration/devices.csv).
|
||||
#
|
||||
# service args...
|
||||
/system/services/input
|
||||
|
@@ -0,0 +1,175 @@
|
||||
# /system/configuration/protocol.csv — who may claim, and who may reach, a name
|
||||
# under /protocol (docs/os-development/protocol-namespace.md).
|
||||
#
|
||||
# init is the registrar: it serves /protocol, and every bind AND every open is
|
||||
# checked against this file. It is AUTHORITATIVE — a name no row grants cannot be
|
||||
# bound or reached, and a missing file means nothing may be bound or reached at
|
||||
# all.
|
||||
#
|
||||
# A refused open is answered exactly as a name nobody bound is: -ENOENT, and no
|
||||
# capability. That is not politeness, it is the model — the namespace IS the
|
||||
# restriction, so what a process may not open simply does not exist for it, and
|
||||
# there is no "permission denied" for it to tell apart from "no such contract".
|
||||
# Which is why a missing row here shows up as a client retrying forever rather
|
||||
# than as an error: check this file first, and `readdir /protocol` second.
|
||||
#
|
||||
# '#' starts a comment (whole-line or trailing); blank lines are ignored.
|
||||
# Whitespace around a field is trimmed, so columns may be padded. Four
|
||||
# comma-separated fields per row:
|
||||
#
|
||||
# binary the claimant's binary path, exactly as the kernel stamped it at
|
||||
# spawn (argv[0]) — unforgeable, read from the process records
|
||||
# supervisor the authorized supervising TASK, written as the binary it runs —
|
||||
# the path init was started as for its own services, the device
|
||||
# manager's path for the drivers it starts. The one word that is not
|
||||
# a path is 'kernel', because a kernel task has no binary; that is
|
||||
# what the test harness's direct spawns look like.
|
||||
# Matched by IDENTITY, not by spelling. Name alone is not identity —
|
||||
# spawn is ungated, so a hostile process can start a granted binary
|
||||
# itself and inherit its grants; and it can equally start its own
|
||||
# instance of the *supervisor's* binary and have that spawn the
|
||||
# granted one, at which point both names read correctly (the
|
||||
# laundering deputy). So init also asks which task the supervisor
|
||||
# is: 'kernel' means supervisor id 0, which only the kernel can
|
||||
# confer; init's own path means this init; any other path means a
|
||||
# task init spawned itself or one the kernel spawned. Task ids are
|
||||
# monotonic and never reused, so an id cannot be borrowed.
|
||||
# permission bind (provide this contract) | open (speak to it) |
|
||||
# supervise (stand in someone else's chain — see below)
|
||||
# name the contract, relative to /protocol
|
||||
#
|
||||
# A trailing '*' on any field matches any tail — how a subtree is granted whole.
|
||||
#
|
||||
# 'supervise' exists because attestation is one hop deep and the driver tree is
|
||||
# three: the device manager starts the PS/2 bus, and the bus starts the keyboard
|
||||
# and mouse drivers. Init never met the bus, so it cannot vouch for it by
|
||||
# acquaintance — and it must not vouch for it by name, or the laundering deputy
|
||||
# walks straight in. A 'supervise' row is the manifest saying it: a task running
|
||||
# this binary, under this supervisor, may be the supervising task an 'open' row
|
||||
# names, for this contract and no other. It grants the delegate nothing itself,
|
||||
# and it is deliberately open-only — a delegate may vouch for what its children
|
||||
# REACH, never for what they CLAIM, so every bind refusal is untouched by it.
|
||||
#
|
||||
# binary supervisor permission name
|
||||
|
||||
# --- the services init spawns from init.csv ---------------------------------
|
||||
/system/services/input, /system/services/init, bind, input
|
||||
/system/services/device-manager, /system/services/init, bind, device-manager
|
||||
/system/services/fat, /system/services/init, bind, vfs
|
||||
/system/services/display, /system/services/init, bind, display
|
||||
|
||||
# The discovery service ships under one neutral name per firmware (docs/discovery.md);
|
||||
# on x86 it is the acpi service, and what it provides is the power contract.
|
||||
/system/services/discovery, /system/services/device-manager, bind, power
|
||||
|
||||
# --- the drivers, which the device manager spawns ---------------------------
|
||||
/system/drivers/ps2-bus, /system/services/device-manager, bind, ps2-bus
|
||||
/system/drivers/usb-xhci-bus, /system/services/device-manager, bind, usb-transfer
|
||||
/system/drivers/usb-storage, /system/services/device-manager, bind, block
|
||||
/system/drivers/virtio-gpu, /system/services/device-manager, bind, scanout
|
||||
|
||||
# --- the same providers when the kernel test harness starts them directly ---
|
||||
# A scenario boot spawns its own providers instead of letting init do it
|
||||
# (docs/security-track-plan.md, decision 9), so the same binaries appear with
|
||||
# 'kernel' as the supervisor. Nothing else changes: the binary must still match.
|
||||
/system/services/input, kernel, bind, input
|
||||
/system/services/device-manager, kernel, bind, device-manager
|
||||
/system/services/fat, kernel, bind, vfs
|
||||
/system/services/display, kernel, bind, display
|
||||
/system/services/discovery, kernel, bind, power
|
||||
|
||||
# --- test fixtures ----------------------------------------------------------
|
||||
# The subtree rule, dogfooded: anything installed under /test may claim anything
|
||||
# under /protocol/test, and nothing above it — whether the harness spawned it or
|
||||
# another fixture did.
|
||||
/test/*, kernel, bind, test/*
|
||||
/test/*, /test/*, bind, test/*
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# open — who may REACH each contract. One row per client per contract; a client
|
||||
# with no row here simply finds the name absent, forever.
|
||||
# ============================================================================
|
||||
|
||||
# --- init's own services ----------------------------------------------------
|
||||
# fat reaches the block device behind the volume it mounts; the compositor
|
||||
# reaches the scanout its driver announced, its own endpoint (the mouse-listener
|
||||
# thread opens /protocol/display like any other client — threads share no
|
||||
# handles), and the input stream that moves the cursor.
|
||||
/system/services/fat, /system/services/init, open, block
|
||||
/system/services/display, /system/services/init, open, scanout
|
||||
/system/services/display, /system/services/init, open, display
|
||||
/system/services/display, /system/services/init, open, input
|
||||
/system/services/display-demo, /system/services/init, open, display
|
||||
|
||||
# --- the same two when the kernel test harness starts them directly ---------
|
||||
/system/services/display, kernel, open, scanout
|
||||
/system/services/display, kernel, open, display
|
||||
/system/services/display, kernel, open, input
|
||||
/system/services/display-demo, kernel, open, display
|
||||
|
||||
# --- the drivers, and the discovery service ---------------------------------
|
||||
# Every driver says hello to the manager that started it — one row for the whole
|
||||
# subtree, because that handshake is what being a driver means. The rest are per
|
||||
# driver: the storage and HID class drivers talk to their controller, the HID
|
||||
# drivers publish into the input stream, and the GPU driver announces its scanout
|
||||
# to the compositor.
|
||||
/system/drivers/*, /system/services/device-manager, open, device-manager
|
||||
/system/services/discovery, /system/services/device-manager, open, device-manager
|
||||
/system/drivers/usb-storage, /system/services/device-manager, open, usb-transfer
|
||||
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, usb-transfer
|
||||
/system/drivers/usb-hid-keyboard, /system/services/device-manager, open, input
|
||||
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, usb-transfer
|
||||
/system/drivers/usb-hid-mouse, /system/services/device-manager, open, input
|
||||
/system/drivers/virtio-gpu, /system/services/device-manager, open, display
|
||||
|
||||
# --- the PS/2 child drivers, one hop further down ---------------------------
|
||||
# The keyboard and mouse drivers are started by the BUS driver, not by the
|
||||
# device manager — the one three-deep chain in the tree. Init cannot vouch for
|
||||
# the bus by acquaintance (it never started it), so the manifest authorizes it
|
||||
# explicitly, and only for the two contracts its children need.
|
||||
/system/drivers/ps2-bus, /system/services/device-manager, supervise, ps2-bus
|
||||
/system/drivers/ps2-bus, /system/services/device-manager, supervise, input
|
||||
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, ps2-bus
|
||||
/system/drivers/ps2-keyboard, /system/drivers/ps2-bus, open, input
|
||||
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, ps2-bus
|
||||
/system/drivers/ps2-mouse, /system/drivers/ps2-bus, open, input
|
||||
|
||||
# --- test fixtures ----------------------------------------------------------
|
||||
# The /protocol/test subtree is theirs whole, the way the bind rows give it to
|
||||
# them. Everything ABOVE that subtree is named one fixture at a time, so a
|
||||
# fixture reaches a system contract only where a scenario needs it — which is
|
||||
# what leaves the rest genuinely absent for the rest of them (the protocol-denied
|
||||
# case asks for one it was not given, and is told there is no such thing).
|
||||
/test/*, kernel, open, test/*
|
||||
/test/*, /test/*, open, test/*
|
||||
/test/*, kernel, open, device-manager
|
||||
/test/*, /system/services/device-manager, open, device-manager
|
||||
/test/system/services/input-source, kernel, open, input
|
||||
/test/system/services/input-test, kernel, open, input
|
||||
|
||||
# The guessable-id probe (test/system/services/badge-scope-test) runs as two
|
||||
# processes of one binary: the owner, which the scenario spawns, and the intruder,
|
||||
# which the owner spawns with the ids it holds. Both reach the compositor — the
|
||||
# owner to create the layer, the intruder to be refused it — so the binary is
|
||||
# named twice, once per supervisor. The second row needs no 'supervise'
|
||||
# delegation: the owner was spawned by the KERNEL, which is a chain init can
|
||||
# vouch for on its own.
|
||||
/test/system/services/badge-scope-test, kernel, open, display
|
||||
/test/system/services/badge-scope-test, /test/*, open, display
|
||||
|
||||
# The conformance probe (test/system/services/protocol-conformance-test) asks
|
||||
# every provider its boot bound for the envelope's reserved verbs. It reaches
|
||||
# ONLY the two contracts its own scenario boots a provider for, named one at a
|
||||
# time exactly like the two rows above — no subtree, no wildcard. Everything else
|
||||
# under /protocol stays absent for it, which is the point: the fixture walks the
|
||||
# namespace listing and reports what it could not open rather than being handed
|
||||
# the tree to make the test look broad.
|
||||
/test/system/services/protocol-conformance-test, kernel, open, input
|
||||
/test/system/services/protocol-conformance-test, kernel, open, display
|
||||
|
||||
# The laundering-deputy probe (test/system/services/protocol-registry-test) runs
|
||||
# a grandchild whose supervisor is a fixture nobody authorized — that is the
|
||||
# point of it, and its bind must stay refused. It still has to report the verdict
|
||||
# it got, so its reporting channel, and nothing else, is delegated.
|
||||
/test/*, /test/*, supervise, test/verdict
|
||||
|
Can't render this file because it contains an unexpected character in line 12 and column 15.
|
@@ -1,6 +1,6 @@
|
||||
//! The pci-bus driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
|
||||
@@ -21,7 +21,7 @@ const logging = @import("logging");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
const pci_class = @import("pci-class");
|
||||
|
||||
/// Log a discovered function as its would-be /etc/devices.csv columns (bus, base,
|
||||
/// Log a discovered function as its would-be /system/configuration/devices.csv columns (bus, base,
|
||||
/// class, prog_if, vendor, device, subsystem) followed by the human-readable
|
||||
/// class/subclass/prog-IF names — so a row for a new driver reads straight off the
|
||||
/// boot log. `subsystem` prints as `*` when the function has none, matching the CSV
|
||||
@@ -154,7 +154,7 @@ fn registerAndReport(bus: u64, dev: u64, function: u64, class_triple: u32) void
|
||||
descriptor.class = @intFromEnum(device.DeviceClass.pci_device);
|
||||
descriptor.pci_class = class_triple;
|
||||
// Vendor/device from the first config dword (0x00): low half vendor, high half
|
||||
// device. These carry to the manager's /etc/devices.csv matcher so a function
|
||||
// device. These carry to the manager's /system/configuration/devices.csv matcher so a function
|
||||
// can bind on its exact 1AF4:1050 identity, not just its class triple.
|
||||
const vendor_device = configRead(bus, dev, function, 0x00);
|
||||
descriptor.vendor = @truncate(vendor_device);
|
||||
@@ -229,27 +229,30 @@ fn registerAndReport(bus: u64, dev: u64, function: u64, class_triple: u32) void
|
||||
std.log.info("register refused for {d}:{d}.{d}", .{ bus, dev, function });
|
||||
return;
|
||||
};
|
||||
const report = device_manager_protocol.ChildAdded{
|
||||
// The registered device id is the packet's target — the manager's object
|
||||
// addressing — so the report body carries only where on the bus it sits and
|
||||
// what it is.
|
||||
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
const framed = device_manager_protocol.Protocol.encodeRequest(.child_added, registered, .{
|
||||
.bus = @intFromEnum(device_manager_protocol.BusKind.pci),
|
||||
.parent = bridge_id,
|
||||
.bus_address = (bus << 8) | (dev << 3) | function,
|
||||
.identity = class_triple,
|
||||
.device_id = registered,
|
||||
.vendor = descriptor.vendor,
|
||||
.device = descriptor.device,
|
||||
.subsystem = descriptor.subsystem,
|
||||
};
|
||||
}, &.{}, &packet) orelse return;
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(manager_handle, std.mem.asBytes(&report), &reply) catch {
|
||||
_ = ipc.call(manager_handle, framed, &reply) catch {
|
||||
std.log.info("child report for {d}:{d}.{d} failed", .{ bus, dev, function });
|
||||
};
|
||||
}
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
_ = message;
|
||||
_ = reply;
|
||||
_ = sender;
|
||||
_ = capability;
|
||||
_ = arrived; // nothing here takes a capability: the harness closes what arrives
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The ps2-bus driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
@@ -9,7 +9,7 @@ pub fn build(b: *std.Build) void {
|
||||
const ps2_bus_exe = build_support.userBinary(b, .{
|
||||
.name = "ps2-bus",
|
||||
.root_source_file = b.path("ps2-bus.zig"),
|
||||
.imports = &.{ "acpi-ids", "driver", "ipc", "logging", "memory", "process", "service", "time" },
|
||||
.imports = &.{ "acpi-ids", "channel", "driver", "ipc", "logging", "memory", "process", "service", "time" },
|
||||
});
|
||||
b.installArtifact(ps2_bus_exe);
|
||||
|
||||
@@ -17,8 +17,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "ps2-keyboard",
|
||||
.root_source_file = b.path("keyboard.zig"),
|
||||
.imports = &.{
|
||||
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory",
|
||||
"process", "time", "xkeyboard-config",
|
||||
"acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc",
|
||||
"logging", "memory", "process", "time", "xkeyboard-config",
|
||||
},
|
||||
});
|
||||
b.installArtifact(ps2_keyboard_exe);
|
||||
@@ -27,8 +27,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "ps2-mouse",
|
||||
.root_source_file = b.path("mouse.zig"),
|
||||
.imports = &.{
|
||||
"acpi-ids", "driver", "input-client", "input-protocol", "ipc", "logging", "memory",
|
||||
"process", "time",
|
||||
"acpi-ids", "channel", "driver", "input-client", "input-protocol", "ipc", "logging",
|
||||
"memory", "process", "time",
|
||||
},
|
||||
});
|
||||
b.installArtifact(ps2_mouse_exe);
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
@@ -27,12 +28,12 @@ const ps2 = @import("ps2-library.zig");
|
||||
const scancode = @import("scancode.zig");
|
||||
const input_protocol = @import("input-protocol");
|
||||
|
||||
/// Look up the ps2-bus service, retrying while the bus (which spawned us before
|
||||
/// registering) is still coming up.
|
||||
/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
|
||||
/// binding) is still coming up.
|
||||
fn lookupBus() ?ipc.Handle {
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.ps2_bus)) |handle| return handle;
|
||||
if (channel.openEndpoint("ps2-bus")) |handle| return handle;
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const time = @import("time");
|
||||
@@ -26,12 +27,12 @@ const ps2 = @import("ps2-library.zig");
|
||||
const mouse_packet = @import("mouse-packet.zig");
|
||||
const input_protocol = @import("input-protocol");
|
||||
|
||||
/// Look up the ps2-bus service, retrying while the bus (which spawned us before
|
||||
/// registering) is still coming up.
|
||||
/// Open `/protocol/ps2-bus`, retrying while the bus (which spawned us before
|
||||
/// binding) is still coming up.
|
||||
fn lookupBus() ?ipc.Handle {
|
||||
var attempts: usize = 0;
|
||||
while (attempts < 100) : (attempts += 1) {
|
||||
if (ipc.lookup(.ps2_bus)) |handle| return handle;
|
||||
if (channel.openEndpoint("ps2-bus")) |handle| return handle;
|
||||
time.sleepMillis(50);
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - irq 0xc len 0x1
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
@@ -62,7 +63,13 @@ var port_device_types = [_]?ps2.DeviceType{ null, null };
|
||||
/// Handle a child driver's `AttachRequest`: record the endpoint capability it
|
||||
/// passed as the forwarding target for the port whose device matches its type.
|
||||
/// Writes an `AttachReply` into `out` and returns its length.
|
||||
fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize {
|
||||
/// A child driver's AttachRequest. The endpoint it hands over arrives under the
|
||||
/// same ownership rule the service harness states (`ipc.Arrival`): the turn owns
|
||||
/// it, and only the path that records it in `port_endpoints` says `take`. Every
|
||||
/// refusal here simply returns, and the loop closes what arrived — otherwise a
|
||||
/// stranger (this is a named contract, reachable by anyone) spends one of this
|
||||
/// driver's thirty-two handle slots per malformed attach.
|
||||
fn handleAttach(message: []const u8, out: []u8, arrived: *ipc.Arrival) usize {
|
||||
const reply = struct {
|
||||
fn write(buffer: []u8, status: ps2.AttachStatus) usize {
|
||||
const header = ps2.AttachReply{ .status = @intFromEnum(status) };
|
||||
@@ -73,12 +80,17 @@ fn handleAttach(message: []const u8, got: ipc.Received, out: []u8) usize {
|
||||
|
||||
if (message.len < @sizeOf(ps2.AttachRequest)) return reply.write(out, .invalid_request);
|
||||
const request = std.mem.bytesToValue(ps2.AttachRequest, message[0..@sizeOf(ps2.AttachRequest)]);
|
||||
const endpoint = got.cap orelse return reply.write(out, .missing_endpoint);
|
||||
const endpoint = arrived.peek() orelse return reply.write(out, .missing_endpoint);
|
||||
|
||||
for (&port_device_types, 0..) |maybe_type, port_index| {
|
||||
const device_type = maybe_type orelse continue;
|
||||
if (@intFromEnum(device_type) != request.device_type) continue;
|
||||
port_endpoints[port_index] = endpoint;
|
||||
// Claimed. A re-attach supersedes the previous driver's endpoint, and the
|
||||
// one it displaces is closed: the slot holds exactly one reference.
|
||||
if (port_endpoints[port_index]) |previous| {
|
||||
if (previous != endpoint) _ = ipc.close(previous);
|
||||
}
|
||||
port_endpoints[port_index] = arrived.take();
|
||||
std.log.info("{s} driver attached", .{@tagName(device_type)});
|
||||
return reply.write(out, .ok);
|
||||
}
|
||||
@@ -213,15 +225,16 @@ pub fn main() void {
|
||||
return;
|
||||
};
|
||||
|
||||
// The endpoint the child drivers attach to and IRQ1 wakes. Registered under a
|
||||
// well-known id so the children can find it, the way input subscribers find
|
||||
// the input service.
|
||||
// The endpoint the child drivers attach to and IRQ1 wakes. Bound as the
|
||||
// `ps2-bus` contract so the children can find it by name, the way input
|
||||
// subscribers find the input service. This driver runs its own loop rather
|
||||
// than the service harness, so it binds by hand — same call the harness makes.
|
||||
const endpoint = ipc.createIpcEndpoint() orelse {
|
||||
_ = logging.write("/system/drivers/ps2-bus: no endpoint\n");
|
||||
return;
|
||||
};
|
||||
if (!ipc.register(.ps2_bus, endpoint)) {
|
||||
_ = logging.write("/system/drivers/ps2-bus: register failed\n");
|
||||
if (!channel.bindPatiently("ps2-bus", endpoint)) {
|
||||
_ = logging.write("/system/drivers/ps2-bus: could not bind /protocol/ps2-bus\n");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -273,6 +286,13 @@ pub fn main() void {
|
||||
var receive: [@sizeOf(ps2.AttachRequest)]u8 = undefined;
|
||||
while (true) {
|
||||
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
|
||||
// The turn owns whatever capability arrived and closes it unless
|
||||
// `handleAttach` claims it (`ipc.Arrival`) — the kernel installs one
|
||||
// whatever the message's length or kind, so this covers the notification
|
||||
// path and every refusal below it.
|
||||
var arrived: ipc.Arrival = .{ .handle = got.cap };
|
||||
defer arrived.release();
|
||||
|
||||
if (got.isNotification()) {
|
||||
reply_len = 0;
|
||||
if (got.isMessage() or got.isChildExit()) continue; // nothing sends us these
|
||||
@@ -301,6 +321,6 @@ pub fn main() void {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
reply_len = handleAttach(receive[0..got.len], got, &reply_buffer);
|
||||
reply_len = handleAttach(receive[0..got.len], &reply_buffer, &arrived);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The usb-hid driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
|
||||
@@ -112,9 +112,10 @@ pub fn main(init: process.Init) void {
|
||||
if (signals.has(.terminate)) return;
|
||||
continue;
|
||||
}
|
||||
if (!got.isMessage() or got.len < @sizeOf(usb.InterruptReport)) continue;
|
||||
|
||||
const message = std.mem.bytesToValue(usb.InterruptReport, receive[0..@sizeOf(usb.InterruptReport)]);
|
||||
if (!got.isMessage()) continue;
|
||||
// An `interrupt_report` event packet: the verb in its folded header, the
|
||||
// report after it. Anything else on this endpoint is not ours.
|
||||
const message = usb.reportOf(receive[0..got.len]) orelse continue;
|
||||
if (message.length < @sizeOf(hid.KeyboardReport)) continue;
|
||||
const report = std.mem.bytesToValue(hid.KeyboardReport, message.data[0..@sizeOf(hid.KeyboardReport)]);
|
||||
const transitions = decoder.feed(report);
|
||||
|
||||
@@ -74,9 +74,10 @@ pub fn main(init: process.Init) void {
|
||||
if (signals.has(.terminate)) return;
|
||||
continue;
|
||||
}
|
||||
if (!got.isMessage() or got.len < @sizeOf(usb.InterruptReport)) continue;
|
||||
|
||||
const message = std.mem.bytesToValue(usb.InterruptReport, receive[0..@sizeOf(usb.InterruptReport)]);
|
||||
if (!got.isMessage()) continue;
|
||||
// An `interrupt_report` event packet: the verb in its folded header, the
|
||||
// report after it. Anything else on this endpoint is not ours.
|
||||
const message = usb.reportOf(receive[0..got.len]) orelse continue;
|
||||
const length = @min(message.length, message.data.len);
|
||||
const report = hid.parseMouse(message.data[0..length]) orelse continue;
|
||||
const mask = buttonMask(report.buttons);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The usb-storage driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
@@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "usb-storage",
|
||||
.root_source_file = b.path("usb-storage.zig"),
|
||||
.imports = &.{
|
||||
"block-protocol", "driver", "ipc", "logging", "memory", "process", "service",
|
||||
"time", "usb",
|
||||
"block-protocol", "driver", "envelope", "ipc", "logging", "memory", "process",
|
||||
"service", "time", "usb",
|
||||
},
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
@@ -22,8 +22,16 @@ const logging = @import("logging");
|
||||
const usb = @import("usb");
|
||||
const scsi = @import("scsi.zig");
|
||||
const bot = @import("bulk-only-transport.zig");
|
||||
const envelope = @import("envelope");
|
||||
const block_protocol = @import("block-protocol");
|
||||
|
||||
/// The generated block dispatch. One device per process, so the handler context
|
||||
/// is empty and the geometry stays in this file's globals.
|
||||
const Serve = block_protocol.Protocol.Provider(void);
|
||||
|
||||
const Invocation = envelope.Invocation;
|
||||
const Answer = envelope.Answer;
|
||||
|
||||
var device_id: u64 = 0;
|
||||
var device: usb.Device = undefined;
|
||||
var bulk_in: usb.Endpoint = undefined;
|
||||
@@ -144,52 +152,65 @@ fn initialise(endpoint: ipc.Handle) bool {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Serve the block protocol: geometry, and whole-block read/write to/from the
|
||||
/// caller's DMA buffer (named by physical address).
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = sender;
|
||||
if (message.len < block_protocol.request_size) return 0;
|
||||
const request = std.mem.bytesToValue(block_protocol.Request, message[0..block_protocol.request_size]);
|
||||
switch (request.operation) {
|
||||
@intFromEnum(block_protocol.Operation.attach) => {
|
||||
// The filesystem's DMA buffer: forward its capability to the controller so
|
||||
// the device can reach it, then release our copy (the binding holds a ref).
|
||||
const handle = capability orelse return writeReply(reply, .{ .status = -1, .block_size = 0, .block_count = 0 });
|
||||
const ok = device.attachDma(handle);
|
||||
_ = ipc.close(handle);
|
||||
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = 0, .block_count = 0 });
|
||||
},
|
||||
@intFromEnum(block_protocol.Operation.geometry) => {
|
||||
return writeReply(reply, .{ .status = 0, .block_size = block_size, .block_count = block_count });
|
||||
},
|
||||
@intFromEnum(block_protocol.Operation.read) => {
|
||||
const count: u16 = @intCast(request.count);
|
||||
const cdb = scsi.read10(@intCast(request.lba), count);
|
||||
const ok = transact(&cdb, true, request.physical, request.count * block_size);
|
||||
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
|
||||
},
|
||||
@intFromEnum(block_protocol.Operation.write) => {
|
||||
const count: u16 = @intCast(request.count);
|
||||
const cdb = scsi.write10(@intCast(request.lba), count);
|
||||
const ok = transact(&cdb, false, request.physical, request.count * block_size);
|
||||
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = if (ok) request.count else 0 });
|
||||
},
|
||||
@intFromEnum(block_protocol.Operation.flush) => {
|
||||
// SYNCHRONIZE CACHE: commit the device's write cache to flash. No data
|
||||
// stage. Makes prior writes durable before a caller (init at shutdown)
|
||||
// cuts power. A device without a volatile cache reports success anyway.
|
||||
const cdb = scsi.synchronizeCache10();
|
||||
const ok = transact(&cdb, false, 0, 0);
|
||||
return writeReply(reply, .{ .status = if (ok) 0 else -1, .block_size = block_size, .block_count = 0 });
|
||||
},
|
||||
else => return 0,
|
||||
}
|
||||
// --- serving the block protocol ---------------------------------------------
|
||||
//
|
||||
// Geometry, and whole-block read/write to and from the caller's DMA buffer
|
||||
// (named by physical address). One device per process, so `Header.target` is
|
||||
// always 0 and no handler reads it.
|
||||
|
||||
/// A transfer the device refused. Every failure here is the same one — the SCSI
|
||||
/// command did not complete — so there is one errno for all of them.
|
||||
const refused: isize = -envelope.ENOENT;
|
||||
|
||||
fn onGeometry(_: void, _: Invocation(void), answer: Answer(block_protocol.Geometry)) isize {
|
||||
answer.set(.{ .block_size = block_size, .block_count = block_count });
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn writeReply(reply: []u8, value: block_protocol.Reply) usize {
|
||||
const bytes = std.mem.asBytes(&value);
|
||||
@memcpy(reply[0..bytes.len], bytes);
|
||||
return bytes.len;
|
||||
fn onRead(_: void, invocation: Invocation(block_protocol.Transfer), answer: Answer(block_protocol.Transferred)) isize {
|
||||
const request = invocation.request;
|
||||
const cdb = scsi.read10(@intCast(request.lba), @intCast(request.count));
|
||||
if (!transact(&cdb, true, request.physical, request.count * block_size)) return refused;
|
||||
answer.set(.{ .count = request.count });
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onWrite(_: void, invocation: Invocation(block_protocol.Transfer), answer: Answer(block_protocol.Transferred)) isize {
|
||||
const request = invocation.request;
|
||||
const cdb = scsi.write10(@intCast(request.lba), @intCast(request.count));
|
||||
if (!transact(&cdb, false, request.physical, request.count * block_size)) return refused;
|
||||
answer.set(.{ .count = request.count });
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// SYNCHRONIZE CACHE: commit the device's write cache to flash. No data stage.
|
||||
/// Makes prior writes durable before a caller (init at shutdown) cuts power. A
|
||||
/// device without a volatile cache reports success anyway.
|
||||
fn onFlush(_: void, _: Invocation(void), _: Answer(void)) isize {
|
||||
const cdb = scsi.synchronizeCache10();
|
||||
return if (transact(&cdb, false, 0, 0)) 0 else refused;
|
||||
}
|
||||
|
||||
/// The filesystem's DMA buffer: forward its capability to the controller so the
|
||||
/// device can reach it. Never claimed — the binding holds its own reference, so
|
||||
/// our copy is the turn's to close, on this path and on the refusal alike.
|
||||
fn onAttach(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
const handle = invocation.capability orelse return -envelope.EPROTO;
|
||||
return if (device.attachDma(handle)) 0 else refused;
|
||||
}
|
||||
|
||||
const handlers = Serve.Handlers{
|
||||
.geometry = onGeometry,
|
||||
.read = onRead,
|
||||
.write = onWrite,
|
||||
.flush = onFlush,
|
||||
.attach = onAttach,
|
||||
};
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
// Peeked, never taken: `attach` forwards the capability and the controller's
|
||||
// binding takes its own reference, so this copy stays the turn's to close.
|
||||
return Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
@@ -202,7 +223,7 @@ pub fn main(init: process.Init) void {
|
||||
return;
|
||||
};
|
||||
service.run(block_protocol.message_maximum, .{
|
||||
.service = .block,
|
||||
.service = "block",
|
||||
.init = initialise,
|
||||
.on_message = onMessage,
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The usb-xhci-bus driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
@@ -10,9 +10,10 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "usb-xhci-bus",
|
||||
.root_source_file = b.path("usb-xhci-bus.zig"),
|
||||
.imports = &.{
|
||||
"device-manager-protocol", "driver", "input-client", "ipc", "logging", "memory",
|
||||
"mmio", "pci", "process", "service", "time", "usb-abi", "usb-ids",
|
||||
"usb-transfer-protocol",
|
||||
"channel", "device-manager-protocol", "driver", "envelope",
|
||||
"input-client", "ipc", "logging", "memory",
|
||||
"mmio", "pci", "process", "service",
|
||||
"time", "usb-abi", "usb-ids", "usb-transfer-protocol",
|
||||
},
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
@@ -24,6 +25,7 @@ const device_manager = @import("driver");
|
||||
const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
const envelope = @import("envelope");
|
||||
const usb_ids = @import("usb-ids");
|
||||
const usb_abi = @import("usb-abi");
|
||||
const usb_transfer_protocol = @import("usb-transfer-protocol");
|
||||
@@ -59,28 +61,65 @@ fn timerInterval() u64 {
|
||||
return if (msi_vector != null) reconcile_interval_ms else poll_interval_ms;
|
||||
}
|
||||
|
||||
/// The class driver endpoints that opened each device, so interrupt reports can
|
||||
/// be pushed back to them. Keyed by the device token (the interface's device id).
|
||||
/// The class driver that opened each device: the endpoint interrupt reports are
|
||||
/// pushed back to, and **the task that opened it** — the kernel-stamped badge, so
|
||||
/// a device token is scoped to the client that was given it. Keyed by the device
|
||||
/// token (the interface's device id).
|
||||
///
|
||||
/// The scoping is the point (docs/os-development/protocol-namespace.md: handles
|
||||
/// are validated against the badge). A token is a small registered-device id any
|
||||
/// process could name, and every packet that carries one used to be honoured from
|
||||
/// anyone: a stranger could run control transfers on another driver's device, arm
|
||||
/// interrupt polling on it, or redirect its reports.
|
||||
const Open = struct {
|
||||
used: bool = false,
|
||||
device_token: u64 = 0,
|
||||
owner: u32 = 0,
|
||||
report_endpoint: usize = 0,
|
||||
};
|
||||
var opens = [_]Open{.{}} ** 16;
|
||||
|
||||
fn recordOpen(device_token: u64, report_endpoint: usize) void {
|
||||
/// Remember (or replace) the endpoint task `owner` receives reports for
|
||||
/// `device_token` on. Returns whether the table kept the handle — false means the
|
||||
/// caller still owns it and must dispose of it. A re-open by the **same** client
|
||||
/// supersedes its previous endpoint, and the one it displaced is closed here: the
|
||||
/// table holds exactly one reference per slot.
|
||||
fn recordOpen(device_token: u64, owner: u32, report_endpoint: usize) bool {
|
||||
for (&opens) |*open| {
|
||||
if (open.used and open.device_token == device_token) {
|
||||
if (open.owner != owner) return false; // someone else's device; nothing kept
|
||||
if (open.report_endpoint != report_endpoint) _ = ipc.close(open.report_endpoint);
|
||||
open.report_endpoint = report_endpoint;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (&opens) |*open| {
|
||||
if (!open.used) {
|
||||
open.* = .{ .used = true, .device_token = device_token, .report_endpoint = report_endpoint };
|
||||
return;
|
||||
open.* = .{ .used = true, .device_token = device_token, .owner = owner, .report_endpoint = report_endpoint };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false; // table full: not kept
|
||||
}
|
||||
|
||||
/// Whether `device_token` is open to task `owner`. An open device belonging to
|
||||
/// someone else answers exactly as one that was never opened, so a prober cannot
|
||||
/// tell another driver's device from an absent one.
|
||||
fn openedBy(device_token: u64, owner: u32) bool {
|
||||
for (&opens) |*open| {
|
||||
if (open.used and open.device_token == device_token) return open.owner == owner;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether `device_token` is open at all. Asked only after `openedBy` has said
|
||||
/// the caller is not the holder, so an answer of true means *someone else* holds
|
||||
/// it — one device, one class driver.
|
||||
fn heldByAnother(device_token: u64) bool {
|
||||
for (&opens) |*open| {
|
||||
if (open.used and open.device_token == device_token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
fn reportEndpointFor(device_token: u64) ?usize {
|
||||
@@ -90,6 +129,23 @@ fn reportEndpointFor(device_token: u64) ?usize {
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Release every device a dead client held: its slot, and the report endpoint
|
||||
/// capability in it. Driven by published process exits — the same sweep idiom the
|
||||
/// FAT server uses for open files and the harness uses for subscribers — which is
|
||||
/// also what lets a restarted class driver re-open the device its predecessor had.
|
||||
fn releaseOpensOf(dead: u32) void {
|
||||
for (&opens) |*open| {
|
||||
if (open.used and open.owner == dead) {
|
||||
// The engine first: it holds this endpoint's handle number per
|
||||
// subscription, and the close below frees that number for reuse.
|
||||
if (controller) |*engine| engine.releaseSubscriptions(open.device_token);
|
||||
_ = ipc.close(open.report_endpoint);
|
||||
std.log.info("released device {d} for dead client {d}", .{ open.device_token, dead });
|
||||
open.* = .{};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var controller_id: u64 = device_manager_protocol.no_device;
|
||||
|
||||
/// Claim the assigned controller, find its register window, and hello the
|
||||
@@ -97,6 +153,26 @@ var controller_id: u64 = device_manager_protocol.no_device;
|
||||
/// manager reads as "meant to stop" — a missing assignment is not a crash loop.
|
||||
fn initialise(endpoint: ipc.Handle) bool {
|
||||
service_endpoint = endpoint;
|
||||
// Device tokens are per-client state, so this driver needs deaths for the
|
||||
// same reason the FAT server does: a class driver that crashes must not keep
|
||||
// its device open, or its restarted instance could never claim it back.
|
||||
_ = process.subscribeExits(endpoint);
|
||||
|
||||
// The transfer contract, bound by hand rather than through the harness's
|
||||
// `.service`, because **losing it is not fatal here**. One machine can carry
|
||||
// several xHCI controllers and the driver model spawns one process per
|
||||
// controller, so several processes provide the same contract for different
|
||||
// hardware — and `/protocol` holds exactly one name, deliberately (addressing
|
||||
// lives inside the protocol, never in the path). Whoever binds first is the
|
||||
// one clients reach by name; a later instance still owns its controller,
|
||||
// enumerates its bus, and reports its children to the device manager, so it
|
||||
// keeps running. **Known gap:** a class driver behind a second controller
|
||||
// cannot reach it — the transfer protocol has no controller field for
|
||||
// `target`, and the fix is either one process multiplexing every controller
|
||||
// or the spawner wiring the child's channel (P5), not a second name.
|
||||
if (!channel.bindPatiently("usb-transfer", endpoint))
|
||||
_ = logging.write("/system/drivers/usb-xhci-bus: /protocol/usb-transfer is another controller's; serving mine unnamed\n");
|
||||
|
||||
if (!device.claim(controller_id)) {
|
||||
std.log.info("unable to claim controller device {d}", .{controller_id});
|
||||
return false;
|
||||
@@ -363,6 +439,23 @@ fn bringUpBehindHub(manager: ipc.Handle, engine: *library.Controller, hub: *libr
|
||||
if (deviceIsHub(usb_device)) _ = engine.setupHub(usb_device);
|
||||
}
|
||||
|
||||
/// Report one interface gone. A removal is addressed by the composite (parent,
|
||||
/// bus address) — a pair no single `Header.target` can carry — so that stays the
|
||||
/// packet's body and the target addresses the manager itself. `where` names the
|
||||
/// port and interface for the log line, or null where the caller stays quiet
|
||||
/// (a hub subtree collapsing reports a great many at once).
|
||||
fn reportRemoved(manager: ipc.Handle, bus_address: u64, where: ?struct { port: u32, interface: u8 }) void {
|
||||
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
const framed = device_manager_protocol.Protocol.encodeRequest(.child_removed, 0, .{
|
||||
.parent = controller_id,
|
||||
.bus_address = bus_address,
|
||||
}, &.{}, &packet) orelse return;
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(manager, framed, &reply) catch {
|
||||
if (where) |place| std.log.info("child-removed report for port {d} interface {d} failed", .{ place.port, place.interface });
|
||||
};
|
||||
}
|
||||
|
||||
/// Tear down a device that disconnected from a hub: recursively tear down its
|
||||
/// own downstream devices first if it is a hub, report each interface removed,
|
||||
/// then Disable Slot. Mirrors tearDownPort for a hub-attached device.
|
||||
@@ -375,12 +468,7 @@ fn tearDownHubDevice(manager: ipc.Handle, engine: *library.Controller, dev: *lib
|
||||
const key = hubPortKey(dev.parent_slot, dev.parent_port);
|
||||
for (dev.interfaces[0..dev.interface_count]) |*interface| {
|
||||
if (interface.registered_device_id == 0) continue;
|
||||
const event = device_manager_protocol.ChildRemoved{
|
||||
.parent = controller_id,
|
||||
.bus_address = (@as(u64, key) << 8) | interface.number,
|
||||
};
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(manager, std.mem.asBytes(&event), &reply) catch {};
|
||||
reportRemoved(manager, (@as(u64, key) << 8) | interface.number, null);
|
||||
interface.registered_device_id = 0;
|
||||
}
|
||||
engine.tearDownDevice(dev);
|
||||
@@ -405,14 +493,7 @@ fn tearDownPort(manager: ipc.Handle, engine: *library.Controller, port: u32) voi
|
||||
std.log.info("port {d} disconnected", .{port});
|
||||
for (usb_device.interfaces[0..usb_device.interface_count]) |*interface| {
|
||||
if (interface.registered_device_id == 0) continue;
|
||||
const event = device_manager_protocol.ChildRemoved{
|
||||
.parent = controller_id,
|
||||
.bus_address = (@as(u64, port) << 8) | interface.number,
|
||||
};
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(manager, std.mem.asBytes(&event), &reply) catch {
|
||||
std.log.info("child-removed report for port {d} interface {d} failed", .{ port, interface.number });
|
||||
};
|
||||
reportRemoved(manager, (@as(u64, port) << 8) | interface.number, .{ .port = port, .interface = interface.number });
|
||||
interface.registered_device_id = 0;
|
||||
}
|
||||
engine.tearDownDevice(usb_device);
|
||||
@@ -446,20 +527,22 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI
|
||||
return null;
|
||||
};
|
||||
|
||||
const report = device_manager_protocol.ChildAdded{
|
||||
// The registered device id is the packet's target — the manager's object
|
||||
// addressing — so the report body carries only where on the bus it sits.
|
||||
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
const framed = device_manager_protocol.Protocol.encodeRequest(.child_added, registered, .{
|
||||
.bus = @intFromEnum(device_manager_protocol.BusKind.usb),
|
||||
.parent = controller_id,
|
||||
.bus_address = (@as(u64, port) << 8) | interface.number,
|
||||
.identity = identity,
|
||||
.device_id = registered,
|
||||
};
|
||||
}, &.{}, &packet) orelse return null;
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(manager, std.mem.asBytes(&report), &reply) catch {
|
||||
_ = ipc.call(manager, framed, &reply) catch {
|
||||
std.log.info("child report for port {d} interface {d} failed", .{ port, interface.number });
|
||||
return null;
|
||||
};
|
||||
// The devices.csv columns (bus=usb, and the class triple as base/class/prog_if)
|
||||
// then the human-readable interface name — a would-be /etc/devices.csv row read
|
||||
// then the human-readable interface name — a would-be /system/configuration/devices.csv row read
|
||||
// straight off the boot log.
|
||||
std.log.info("port {d} interface {d} bus=usb base={X:0>2} class={X:0>2} prog_if={X:0>2} — {s} registered as device {d}", .{
|
||||
port,
|
||||
@@ -473,54 +556,60 @@ fn reportInterface(manager: ipc.Handle, port: u32, interface: library.InterfaceI
|
||||
return registered;
|
||||
}
|
||||
|
||||
/// Serve the USB transfer protocol: a class driver opens its device, then issues
|
||||
/// control / interrupt-subscribe / bulk requests against it.
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = sender;
|
||||
if (message.len < 4) return 0;
|
||||
const operation = std.mem.readInt(u32, message[0..4], .little);
|
||||
return switch (operation) {
|
||||
@intFromEnum(usb_transfer_protocol.Operation.open) => handleOpen(message, reply, capability),
|
||||
@intFromEnum(usb_transfer_protocol.Operation.control) => handleControl(message, reply),
|
||||
@intFromEnum(usb_transfer_protocol.Operation.interrupt_subscribe) => handleSubscribe(message, reply),
|
||||
@intFromEnum(usb_transfer_protocol.Operation.bulk) => handleBulk(message, reply),
|
||||
@intFromEnum(usb_transfer_protocol.Operation.dma_attach) => handleDmaAttach(message, reply, capability),
|
||||
else => 0,
|
||||
};
|
||||
/// The generated transfer dispatch. One controller per process, so the handler
|
||||
/// context is empty and the open table stays in this file's globals.
|
||||
const Serve = usb_transfer_protocol.Protocol.Provider(void);
|
||||
|
||||
const Invocation = envelope.Invocation;
|
||||
const Answer = envelope.Answer;
|
||||
|
||||
/// Every refusal here is the same one — this controller does not (or no longer)
|
||||
/// serve the device the packet addressed — so there is one errno for all of them.
|
||||
const refused: isize = -envelope.ENOENT;
|
||||
|
||||
/// Set by `onOpen` when the open table has taken ownership of the endpoint the
|
||||
/// call carried, and read by `onMessage`, where the turn's `Arrival` lives. The
|
||||
/// generated dispatch hands a handler the raw handle rather than the `Arrival`,
|
||||
/// so the *claim* travels back out this way. One turn, one handler, one thread.
|
||||
var capability_claimed = false;
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
capability_claimed = false;
|
||||
const written = Serve.dispatch({}, handlers, message, sender, arrived.peek(), reply);
|
||||
if (capability_claimed) _ = arrived.take();
|
||||
return written;
|
||||
}
|
||||
|
||||
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU
|
||||
/// domain, so the controller may DMA to the physical addresses inside that buffer. The
|
||||
/// binding holds its own kernel reference, so the forwarded capability is closed here.
|
||||
fn handleDmaAttach(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
|
||||
if (message.len < @sizeOf(usb_transfer_protocol.DmaAttachRequest)) return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
|
||||
const handle = capability orelse return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = -1 });
|
||||
const ok = device.dmaBind(controller_id, handle);
|
||||
_ = ipc.close(handle);
|
||||
return writeReply(reply, usb_transfer_protocol.DmaAttachReply{ .status = if (ok) 0 else -1 });
|
||||
}
|
||||
const handlers = Serve.Handlers{
|
||||
.open = onOpen,
|
||||
.control = onControl,
|
||||
.interrupt_subscribe = onInterruptSubscribe,
|
||||
.bulk = onBulk,
|
||||
.dma_attach = onDmaAttach,
|
||||
};
|
||||
|
||||
fn writeReply(reply: []u8, value: anytype) usize {
|
||||
const bytes = std.mem.asBytes(&value);
|
||||
@memcpy(reply[0..bytes.len], bytes);
|
||||
return bytes.len;
|
||||
}
|
||||
/// open: the target is the class driver's assigned device id. Resolve it to an
|
||||
/// interface, remember the caller's endpoint (for interrupt reports), and answer
|
||||
/// with a device token — the target of every later packet — plus the interface's
|
||||
/// endpoints, so the class driver need not re-read the configuration descriptor.
|
||||
fn onOpen(_: void, invocation: Invocation(void), answer: Answer(usb_transfer_protocol.Opened)) isize {
|
||||
const engine = if (controller) |*c| c else return refused;
|
||||
const found = engine.findInterface(invocation.target) orelse return refused;
|
||||
// A device another live client holds is refused exactly as an absent one: one
|
||||
// device, one class driver. The predecessor's slot is released by the exit
|
||||
// sweep, and notifications are delivered ahead of requests, so a *restarted*
|
||||
// driver's open always finds the device free.
|
||||
if (!openedBy(invocation.target, invocation.sender) and heldByAnother(invocation.target)) return refused;
|
||||
|
||||
/// open: resolve the assigned device id to an interface, remember the caller's
|
||||
/// endpoint (for interrupt reports), and answer with a device token + the
|
||||
/// interface's endpoints so the class driver need not re-read the config.
|
||||
fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
|
||||
if (message.len < @sizeOf(usb_transfer_protocol.OpenRequest)) return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
|
||||
const request = std.mem.bytesToValue(usb_transfer_protocol.OpenRequest, message[0..@sizeOf(usb_transfer_protocol.OpenRequest)]);
|
||||
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
|
||||
const found = engine.findInterface(request.device_id) orelse return writeReply(reply, usb_transfer_protocol.OpenReply{ .status = -1, .endpoint_count = 0, .device_token = 0, .interface_class = 0, .interface_subclass = 0, .interface_protocol = 0, .interface_number = 0 });
|
||||
// The report endpoint is claimed only if the open table actually keeps it;
|
||||
// a full table leaves it to the turn to close.
|
||||
if (invocation.capability) |endpoint| {
|
||||
if (recordOpen(invocation.target, invocation.sender, endpoint)) capability_claimed = true;
|
||||
}
|
||||
|
||||
if (capability) |endpoint| recordOpen(request.device_id, endpoint);
|
||||
|
||||
var open_reply = usb_transfer_protocol.OpenReply{
|
||||
.status = 0,
|
||||
var opened = usb_transfer_protocol.Opened{
|
||||
.device_token = invocation.target,
|
||||
.endpoint_count = found.interface.endpoint_count,
|
||||
.device_token = request.device_id,
|
||||
.interface_class = found.interface.class,
|
||||
.interface_subclass = found.interface.subclass,
|
||||
.interface_protocol = found.interface.protocol,
|
||||
@@ -528,57 +617,74 @@ fn handleOpen(message: []const u8, reply: []u8, capability: ?ipc.Handle) usize {
|
||||
};
|
||||
const count = @min(found.interface.endpoint_count, usb_transfer_protocol.max_reported_endpoints);
|
||||
for (found.interface.endpoints[0..count], 0..) |endpoint, index| {
|
||||
open_reply.endpoints[index] = .{
|
||||
opened.endpoints[index] = .{
|
||||
.address = endpoint.address,
|
||||
.transfer_type = endpoint.transfer_type,
|
||||
.max_packet_size = endpoint.max_packet_size,
|
||||
.interval = endpoint.interval,
|
||||
};
|
||||
}
|
||||
return writeReply(reply, open_reply);
|
||||
answer.set(opened);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// control: one EP0 control transfer, small data inline both ways.
|
||||
fn handleControl(message: []const u8, reply: []u8) usize {
|
||||
if (message.len < @sizeOf(usb_transfer_protocol.ControlRequest)) return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
|
||||
const request = std.mem.bytesToValue(usb_transfer_protocol.ControlRequest, message[0..@sizeOf(usb_transfer_protocol.ControlRequest)]);
|
||||
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
|
||||
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.ControlReply{ .status = -1, .actual_length = 0 });
|
||||
/// control: one EP0 control transfer. The data stage rides the tail in both
|
||||
/// directions, so an IN transfer's answer is simply however many bytes were
|
||||
/// written into `answer.tail()` — which is what `Status.len` then reports.
|
||||
fn onControl(_: void, invocation: Invocation(usb_transfer_protocol.Control), answer: Answer(void)) isize {
|
||||
const engine = if (controller) |*c| c else return refused;
|
||||
if (!openedBy(invocation.target, invocation.sender)) return refused;
|
||||
const found = engine.findInterface(invocation.target) orelse return refused;
|
||||
|
||||
const setup = std.mem.bytesToValue(usb_abi.Request, &invocation.request.setup);
|
||||
const direction_in = invocation.request.direction_in != 0;
|
||||
const room = @min(usb_transfer_protocol.max_inline_data, answer.tail().len);
|
||||
const data_length = @min(@as(usize, invocation.request.data_length), room);
|
||||
|
||||
const setup = std.mem.bytesToValue(usb_abi.Request, &request.setup);
|
||||
const direction_in = request.direction_in != 0;
|
||||
const data_length = @min(request.data_length, usb_transfer_protocol.max_inline_data);
|
||||
var data: [usb_transfer_protocol.max_inline_data]u8 = undefined;
|
||||
if (!direction_in) @memcpy(data[0..data_length], request.data[0..data_length]);
|
||||
if (!direction_in) {
|
||||
const supplied = @min(data_length, invocation.tail.len);
|
||||
@memcpy(data[0..supplied], invocation.tail[0..supplied]);
|
||||
if (supplied < data_length) @memset(data[supplied..data_length], 0);
|
||||
}
|
||||
|
||||
const ok = engine.controlTransfer(found.device, setup, data[0..data_length], direction_in);
|
||||
var control_reply = usb_transfer_protocol.ControlReply{ .status = if (ok) 0 else -1, .actual_length = if (ok) data_length else 0 };
|
||||
if (ok and direction_in) @memcpy(control_reply.data[0..data_length], data[0..data_length]);
|
||||
return writeReply(reply, control_reply);
|
||||
if (!engine.controlTransfer(found.device, setup, data[0..data_length], direction_in)) return refused;
|
||||
if (!direction_in) return 0; // nothing follows an OUT: the status is the whole answer
|
||||
@memcpy(answer.tail()[0..data_length], data[0..data_length]);
|
||||
return @intCast(data_length);
|
||||
}
|
||||
|
||||
/// interrupt_subscribe: arm periodic IN polling; reports flow back asynchronously.
|
||||
fn handleSubscribe(message: []const u8, reply: []u8) usize {
|
||||
if (message.len < @sizeOf(usb_transfer_protocol.InterruptSubscribeRequest)) return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
|
||||
const request = std.mem.bytesToValue(usb_transfer_protocol.InterruptSubscribeRequest, message[0..@sizeOf(usb_transfer_protocol.InterruptSubscribeRequest)]);
|
||||
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
|
||||
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
|
||||
const endpoint = library.Controller.endpointForAddress(found.interface, request.endpoint_address) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
|
||||
const report_endpoint = reportEndpointFor(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = -1 });
|
||||
const ok = engine.subscribeInterrupt(found.device, endpoint, request.device_token, report_endpoint);
|
||||
return writeReply(reply, usb_transfer_protocol.InterruptSubscribeReply{ .status = if (ok) 0 else -1 });
|
||||
/// interrupt_subscribe: arm periodic IN polling; reports flow back asynchronously
|
||||
/// to the endpoint this device's `open` handed over.
|
||||
fn onInterruptSubscribe(_: void, invocation: Invocation(usb_transfer_protocol.InterruptSubscribe), _: Answer(void)) isize {
|
||||
const engine = if (controller) |*c| c else return refused;
|
||||
if (!openedBy(invocation.target, invocation.sender)) return refused;
|
||||
const found = engine.findInterface(invocation.target) orelse return refused;
|
||||
const endpoint = library.Controller.endpointForAddress(found.interface, invocation.request.endpoint_address) orelse return refused;
|
||||
const report_endpoint = reportEndpointFor(invocation.target) orelse return refused;
|
||||
return if (engine.subscribeInterrupt(found.device, endpoint, invocation.target, report_endpoint)) 0 else refused;
|
||||
}
|
||||
|
||||
/// bulk: one bulk transfer to/from the class driver's own DMA buffer (by physical
|
||||
/// address), so sector-sized data never crosses IPC.
|
||||
fn handleBulk(message: []const u8, reply: []u8) usize {
|
||||
if (message.len < @sizeOf(usb_transfer_protocol.BulkRequest)) return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
|
||||
const request = std.mem.bytesToValue(usb_transfer_protocol.BulkRequest, message[0..@sizeOf(usb_transfer_protocol.BulkRequest)]);
|
||||
const engine = if (controller) |*c| c else return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
|
||||
const found = engine.findInterface(request.device_token) orelse return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
|
||||
const endpoint = library.Controller.endpointForAddress(found.interface, request.endpoint_address) orelse return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = -1, .actual_length = 0 });
|
||||
const transferred = engine.bulkTransfer(found.device, endpoint, request.physical_address, request.length);
|
||||
return writeReply(reply, usb_transfer_protocol.BulkReply{ .status = if (transferred != null) 0 else -1, .actual_length = transferred orelse 0 });
|
||||
fn onBulk(_: void, invocation: Invocation(usb_transfer_protocol.Bulk), answer: Answer(usb_transfer_protocol.Transferred)) isize {
|
||||
const engine = if (controller) |*c| c else return refused;
|
||||
if (!openedBy(invocation.target, invocation.sender)) return refused;
|
||||
const found = engine.findInterface(invocation.target) orelse return refused;
|
||||
const endpoint = library.Controller.endpointForAddress(found.interface, invocation.request.endpoint_address) orelse return refused;
|
||||
const transferred = engine.bulkTransfer(found.device, endpoint, invocation.request.physical_address, invocation.request.length) orelse return refused;
|
||||
answer.set(.{ .actual_length = transferred });
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// dma_attach: bind the class driver's DMA-region capability into the controller's IOMMU
|
||||
/// domain, so the controller may DMA to the physical addresses inside that buffer. The
|
||||
/// binding holds its own kernel reference, so this never claims the arriving handle —
|
||||
/// the turn's `defer` in the harness is the close, on the failure paths as well as this
|
||||
/// one.
|
||||
fn onDmaAttach(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
const handle = invocation.capability orelse return -envelope.EPROTO;
|
||||
return if (device.dmaBind(controller_id, handle)) 0 else refused;
|
||||
}
|
||||
|
||||
/// A timer tick or an MSI landed: drain the event ring, reconcile ports, and fan out.
|
||||
@@ -587,6 +693,12 @@ fn handleBulk(message: []const u8, reply: []u8) usize {
|
||||
/// arriving after the drain takes IP 0→1 and fires a fresh edge instead of being
|
||||
/// swallowed until the reconcile tick.
|
||||
fn onNotification(badge: u64) void {
|
||||
if (badge & ipc.notify_exit_bit != 0) {
|
||||
// A class driver died: release the devices it held, so its successor can
|
||||
// open them and no report is aimed at an endpoint that is gone.
|
||||
releaseOpensOf(@intCast(badge & ~(ipc.notify_badge_bit | ipc.notify_exit_bit)));
|
||||
return;
|
||||
}
|
||||
if (badge & ipc.notify_timer_bit != 0) {
|
||||
serviceController();
|
||||
_ = time.timerOnce(service_endpoint, timerInterval());
|
||||
@@ -651,14 +763,18 @@ fn serviceController() void {
|
||||
if (serviced >= 32) break;
|
||||
}
|
||||
while (engine.takeReport()) |report| {
|
||||
var message = usb_transfer_protocol.InterruptReport{
|
||||
.device_token = report.device_token,
|
||||
.endpoint_address = report.endpoint_address,
|
||||
.length = @intCast(@min(report.length, usb_transfer_protocol.max_report_data)),
|
||||
};
|
||||
// One `interrupt_report` event packet: the device token in the folded
|
||||
// header, the report after it. A device that produced more than the
|
||||
// push floor admits has its report truncated here, never split.
|
||||
const n = @min(report.length, usb_transfer_protocol.max_report_data);
|
||||
@memcpy(message.data[0..n], report.data[0..n]);
|
||||
_ = ipc.send(report.report_endpoint, std.mem.asBytes(&message));
|
||||
var payload = usb_transfer_protocol.InterruptReport{
|
||||
.endpoint_address = report.endpoint_address,
|
||||
.length = @intCast(n),
|
||||
};
|
||||
@memcpy(payload.data[0..n], report.data[0..n]);
|
||||
var packet: [envelope.post_maximum]u8 = undefined;
|
||||
const framed = usb_transfer_protocol.Protocol.encodeEvent(.interrupt_report, report.device_token, payload, &packet) orelse continue;
|
||||
_ = ipc.send(report.report_endpoint, framed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -673,7 +789,8 @@ pub fn main(init: process.Init) void {
|
||||
return;
|
||||
};
|
||||
service.run(usb_transfer_protocol.message_maximum, .{
|
||||
.service = .usb_bus,
|
||||
// No `.service`: the contract is bound inside `initialise`, where losing
|
||||
// it to another controller's driver is survivable rather than fatal.
|
||||
.init = initialise,
|
||||
.on_message = onMessage,
|
||||
.on_notification = onNotification,
|
||||
|
||||
@@ -1498,6 +1498,24 @@ pub const Controller = struct {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Stop reporting for `device_token`: deactivate every subscription tagged
|
||||
/// with it, so no further report is queued and the slot can be reused. Called
|
||||
/// when the class driver that owned the token dies — its endpoint handle is
|
||||
/// closed with it, and a queued report would then be aimed at a handle number
|
||||
/// the bus driver has since given to something else. In-process hub
|
||||
/// subscriptions carry no token and are never touched.
|
||||
///
|
||||
/// One completion may already be in flight; it finds no active subscription
|
||||
/// and is dropped as a foreign transfer event, which is exactly what it is.
|
||||
pub fn releaseSubscriptions(self: *Controller, device_token: u64) void {
|
||||
for (&self.subscriptions) |*subscription| {
|
||||
if (!subscription.active or subscription.hub != null) continue;
|
||||
if (subscription.device_token != device_token) continue;
|
||||
subscription.active = false;
|
||||
subscription.report_endpoint = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Arm (or re-arm) a subscription's endpoint with a Normal TRB pointing at its
|
||||
// report buffer, and ring the endpoint's doorbell so the controller polls it.
|
||||
fn armInterrupt(self: *Controller, subscription: *Subscription) void {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The virtio-gpu driver as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
|
||||
const std = @import("std");
|
||||
const build_support = @import("build-support");
|
||||
@@ -10,8 +10,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "virtio-gpu",
|
||||
.root_source_file = b.path("virtio-gpu.zig"),
|
||||
.imports = &.{
|
||||
"display-protocol", "driver", "ipc", "logging", "memory", "mmio", "pci", "process",
|
||||
"scanout-protocol", "service", "time",
|
||||
"channel", "display-protocol", "driver", "envelope", "ipc", "logging", "memory",
|
||||
"mmio", "pci", "process", "scanout-protocol", "service", "time",
|
||||
},
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
@@ -24,11 +25,19 @@ const memory = @import("memory");
|
||||
const logging = @import("logging");
|
||||
const mmio = @import("mmio");
|
||||
const pci = @import("pci");
|
||||
const envelope = @import("envelope");
|
||||
const display_protocol = @import("display-protocol");
|
||||
const scanout_protocol = @import("scanout-protocol");
|
||||
const vp = @import("virtio-pci.zig");
|
||||
const vg = @import("virtio-gpu-protocol.zig");
|
||||
|
||||
/// The generated scanout dispatch. One scanout per driver instance, so the
|
||||
/// handler context is empty and the mode stays in this file's globals.
|
||||
const Serve = scanout_protocol.Protocol.Provider(void);
|
||||
|
||||
const Invocation = envelope.Invocation;
|
||||
const Answer = envelope.Answer;
|
||||
|
||||
/// The DisplayFormat (device-abi) our B8G8R8X8 scanout resource presents: bgrx = 1. Handed to
|
||||
/// the compositor in the announce so it packs colours in the surface's byte order.
|
||||
const display_format_bgrx: u32 = 1;
|
||||
@@ -205,7 +214,7 @@ fn initialise(endpoint: ipc.Handle) bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
// Config space is resource 0. The registry (/etc/devices.csv) bound this driver by the
|
||||
// Config space is resource 0. The registry (/system/configuration/devices.csv) bound this driver by the
|
||||
// exact virtio-gpu identity (vendor 0x1AF4 / device 0x1050), so there is no re-confirm to
|
||||
// do here any more — map config space and enable memory-space decode + bus mastering (the
|
||||
// device DMAs the ring and backing out of RAM; pci-bus only preserves whatever the firmware
|
||||
@@ -475,66 +484,66 @@ fn presentFull() bool {
|
||||
fn announce() void {
|
||||
var tries: u32 = 0;
|
||||
const display = while (tries < 50) : (tries += 1) {
|
||||
if (ipc.lookup(.display)) |h| break h;
|
||||
if (channel.openEndpoint("display")) |h| break h;
|
||||
time.sleepMillis(20);
|
||||
} else {
|
||||
std.log.info("no display service to announce to (scanout-only)", .{});
|
||||
return;
|
||||
};
|
||||
var request = display_protocol.Request{
|
||||
.operation = @intFromEnum(display_protocol.Operation.attach_scanout),
|
||||
.x = max_width, // the shared surface's row stride in pixels (it is sized to the max mode)
|
||||
.y = edid_refresh_hz, // the panel refresh from EDID (0 = unknown) — the frame-clock seed
|
||||
var packet: [display_protocol.message_maximum]u8 = undefined;
|
||||
const framed = display_protocol.Protocol.encodeRequest(.attach_scanout, 0, .{
|
||||
.stride = max_width, // the shared surface's row stride in pixels (it is sized to the max mode)
|
||||
.width = current_width,
|
||||
.height = current_height,
|
||||
.colour = display_format_bgrx,
|
||||
};
|
||||
var reply: [display_protocol.reply_size]u8 = undefined;
|
||||
_ = ipc.callCap(display, std.mem.asBytes(&request), &reply, surface.handle) catch {
|
||||
.format = display_format_bgrx,
|
||||
.refresh_hz = edid_refresh_hz, // from EDID (0 = unknown) — the frame-clock seed
|
||||
}, &.{}, &packet) orelse return;
|
||||
var reply: [display_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.callCap(display, framed, &reply, surface.handle) catch {
|
||||
std.log.info("announce to display failed", .{});
|
||||
return;
|
||||
};
|
||||
std.log.info("announced scanout to display", .{});
|
||||
}
|
||||
|
||||
/// A `scanout_protocol.Reply{status}` written into `reply`.
|
||||
fn scanoutStatus(reply: []u8, ok: bool) usize {
|
||||
const response = scanout_protocol.Reply{ .status = if (ok) 0 else -1 };
|
||||
@memcpy(reply[0..scanout_protocol.reply_size], std.mem.asBytes(&response));
|
||||
return scanout_protocol.reply_size;
|
||||
// --- serving the scanout protocol -------------------------------------------
|
||||
//
|
||||
// The compositor drives present / mode queries here. The pixels are already in
|
||||
// the shared surface, so a present is a transfer-to-host + fenced flush; a mode
|
||||
// change just re-points the scanout rectangle (the surface is sized to the
|
||||
// largest mode). One scanout, so `Header.target` is always 0.
|
||||
|
||||
/// The device did not take the frame, or the mode asked for is not one this
|
||||
/// scanout offers.
|
||||
const refused: isize = -envelope.ENOENT;
|
||||
|
||||
fn onPresent(_: void, _: Invocation(scanout_protocol.Present), _: Answer(void)) isize {
|
||||
return if (presentFull()) 0 else refused;
|
||||
}
|
||||
|
||||
/// The `.scanout` service: the compositor drives present / mode queries here. The pixels are
|
||||
/// already in the shared surface, so a present is a transfer-to-host + fenced flush; a mode
|
||||
/// change just re-points the scanout rectangle (the surface is sized to the largest mode).
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
_ = sender;
|
||||
_ = capability;
|
||||
if (message.len < scanout_protocol.request_size) return 0;
|
||||
const request = std.mem.bytesToValue(scanout_protocol.Request, message[0..scanout_protocol.request_size]);
|
||||
switch (request.operation) {
|
||||
@intFromEnum(scanout_protocol.Operation.present) => return scanoutStatus(reply, presentFull()),
|
||||
@intFromEnum(scanout_protocol.Operation.get_modes) => {
|
||||
var response = scanout_protocol.ModesReply{ .status = 0, .count = offered_modes.len, .modes = undefined };
|
||||
for (0..scanout_protocol.max_modes) |i| {
|
||||
response.modes[i] = if (i < offered_modes.len)
|
||||
.{ .width = offered_modes[i].width, .height = offered_modes[i].height }
|
||||
else
|
||||
.{ .width = 0, .height = 0 };
|
||||
}
|
||||
@memcpy(reply[0..scanout_protocol.modes_reply_size], std.mem.asBytes(&response));
|
||||
return scanout_protocol.modes_reply_size;
|
||||
},
|
||||
@intFromEnum(scanout_protocol.Operation.set_mode) => {
|
||||
const w = request.width;
|
||||
const h = request.height;
|
||||
if (w == 0 or h == 0 or w > max_width or h > max_height) return scanoutStatus(reply, false);
|
||||
current_width = w;
|
||||
current_height = h;
|
||||
return scanoutStatus(reply, setScanoutRect());
|
||||
},
|
||||
else => return 0,
|
||||
fn onGetModes(_: void, _: Invocation(void), answer: Answer(scanout_protocol.Modes)) isize {
|
||||
var offered = scanout_protocol.Modes{ .count = offered_modes.len };
|
||||
for (0..@min(offered_modes.len, scanout_protocol.max_modes)) |i| {
|
||||
offered.modes[i] = .{ .width = offered_modes[i].width, .height = offered_modes[i].height };
|
||||
}
|
||||
answer.set(offered);
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn onSetMode(_: void, invocation: Invocation(scanout_protocol.SetMode), _: Answer(void)) isize {
|
||||
const w = invocation.request.width;
|
||||
const h = invocation.request.height;
|
||||
if (w == 0 or h == 0 or w > max_width or h > max_height) return refused;
|
||||
current_width = w;
|
||||
current_height = h;
|
||||
return if (setScanoutRect()) 0 else refused;
|
||||
}
|
||||
|
||||
const handlers = Serve.Handlers{ .present = onPresent, .get_modes = onGetModes, .set_mode = onSetMode };
|
||||
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
_ = arrived; // nothing here takes a capability: the harness closes what arrives
|
||||
return Serve.dispatch({}, handlers, message, sender, null, reply);
|
||||
}
|
||||
|
||||
pub fn main(init: process.Init) void {
|
||||
@@ -546,8 +555,8 @@ pub fn main(init: process.Init) void {
|
||||
std.log.info("malformed device id '{s}'", .{argument});
|
||||
return;
|
||||
};
|
||||
service.run(256, .{
|
||||
.service = .scanout,
|
||||
service.run(scanout_protocol.message_maximum, .{
|
||||
.service = "scanout",
|
||||
.init = initialise,
|
||||
.on_message = onMessage,
|
||||
});
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
const boot_handoff = @import("boot-handoff");
|
||||
const io = @import("io.zig");
|
||||
const cpuid = @import("cpuid.zig");
|
||||
const paging = @import("paging.zig");
|
||||
|
||||
/// The ACPI PM timer, as a calibration reference: an I/O port or MMIO counter.
|
||||
@@ -331,8 +332,8 @@ fn calibratePit() void {
|
||||
/// TSC frequency from CPUID leaf 0x15 (crystal_hz * numerator / denominator), or
|
||||
/// null if the CPU doesn't enumerate it (common under QEMU, and on AMD).
|
||||
fn cpuidTscHz() ?u64 {
|
||||
if (cpuid(0).eax < 0x15) return null;
|
||||
const r = cpuid(0x15);
|
||||
if (!cpuid.supports(0x15)) return null;
|
||||
const r = cpuid.leaf(0x15);
|
||||
if (r.eax == 0 or r.ebx == 0 or r.ecx == 0) return null; // ratio/crystal not given
|
||||
return @as(u64, r.ecx) * r.ebx / r.eax;
|
||||
}
|
||||
@@ -342,26 +343,8 @@ fn cpuidTscHz() ?u64 {
|
||||
/// at a constant rate across P/C-states and never stops. Requires the extended-leaf
|
||||
/// range to reach 0x80000007 first.
|
||||
fn tscIsInvariant() bool {
|
||||
if (cpuid(0x80000000).eax < 0x80000007) return false;
|
||||
return (cpuid(0x80000007).edx & (1 << 8)) != 0;
|
||||
}
|
||||
|
||||
const CpuidRegs = struct { eax: u32, ebx: u32, ecx: u32, edx: u32 };
|
||||
|
||||
fn cpuid(leaf: u32) CpuidRegs {
|
||||
var a: u32 = undefined;
|
||||
var b: u32 = undefined;
|
||||
var c: u32 = undefined;
|
||||
var d: u32 = undefined;
|
||||
asm volatile ("cpuid"
|
||||
: [a] "={eax}" (a),
|
||||
[b] "={ebx}" (b),
|
||||
[c] "={ecx}" (c),
|
||||
[d] "={edx}" (d),
|
||||
: [leaf] "{eax}" (leaf),
|
||||
[sub] "{ecx}" (@as(u32, 0)),
|
||||
);
|
||||
return .{ .eax = a, .ebx = b, .ecx = c, .edx = d };
|
||||
if (!cpuid.supports(0x8000_0007)) return false;
|
||||
return (cpuid.leaf(0x8000_0007).edx & (1 << 8)) != 0;
|
||||
}
|
||||
|
||||
// HPET registers: capabilities at +0x00 (period in the high dword, in fs; bit 13 =
|
||||
|
||||
@@ -141,11 +141,41 @@ pub fn debugconWrite(bytes: []const u8) void {
|
||||
/// stack for double faults), then the IDT with exception handlers. After this a
|
||||
/// CPU fault is reported instead of triple-faulting. Install the fault handler
|
||||
/// (setFaultHandler) first so early faults are caught.
|
||||
///
|
||||
/// This is the **boot processor's** half of per-core bring-up; smp.apEntry is the
|
||||
/// other half and must keep the per-CPU steps in step with it (the system_call
|
||||
/// MSRs and the CR4 hardening bits are per-core state, so every core sets its own).
|
||||
pub fn init() void {
|
||||
gdt.init();
|
||||
tss.init();
|
||||
idt.init();
|
||||
pcpu.initSystemCall();
|
||||
// The machine-wide half of SMAP, and it must precede every CR4 write: the
|
||||
// interrupt entry has to be able to clear EFLAGS.AC before any core claims the
|
||||
// bit. This is the boot processor and the application processors are still
|
||||
// parked, so "before every core" is simply "here".
|
||||
_ = pcpu.armSupervisorAccessPrevention();
|
||||
// Safe this early, before the kernel is on its own page tables: the loader's
|
||||
// bootstrap tables (boot/efi.zig) map with present|writable and never set the
|
||||
// U/S bit, so no page the BSP executes from is user-accessible — and, for SMAP,
|
||||
// no page it *reads* is either, so there is nothing for the new bit to refuse
|
||||
// between here and the switch to the kernel's own tables.
|
||||
pcpu.initHardening();
|
||||
}
|
||||
|
||||
/// Whether ring 0 is barred from executing user-mapped pages on this core (CR4.SMEP
|
||||
/// on x86_64; the privileged-execute-never behaviour elsewhere). False means the CPU
|
||||
/// doesn't offer it and the machine is running unhardened — see per-cpu.zig.
|
||||
pub fn supervisorExecutePreventionEnabled() bool {
|
||||
return pcpu.supervisorExecutePreventionEnabled();
|
||||
}
|
||||
|
||||
/// Whether ring 0 is barred from *reading or writing* user-mapped pages on this core
|
||||
/// (CR4.SMAP on x86_64; the privileged-access-never behaviour elsewhere). False means
|
||||
/// either the CPU doesn't offer it or the interrupt entry could not be armed to clear
|
||||
/// AC — see per-cpu.zig; the machine boots either way, unhardened and saying so.
|
||||
pub fn supervisorAccessPreventionEnabled() bool {
|
||||
return pcpu.supervisorAccessPreventionEnabled();
|
||||
}
|
||||
|
||||
/// Build the kernel's own page tables (with real permissions) and switch onto
|
||||
@@ -257,6 +287,16 @@ pub fn translate(root: u64, virtual: u64) ?u64 {
|
||||
return paging.translateIn(root, virtual);
|
||||
}
|
||||
|
||||
/// `translate` for an address the kernel is about to touch *on a process's
|
||||
/// behalf*: the walk additionally demands the permission ring 3 would need — the
|
||||
/// leaf user-accessible (U/S set at every level), and writable (R/W at every
|
||||
/// level) when `for_write`. Null means "the process itself could not do this",
|
||||
/// which the checked copy layer (system/kernel/user-memory.zig) turns into
|
||||
/// -EFAULT instead of a kernel dereference.
|
||||
pub fn translateUser(root: u64, virtual: u64, for_write: bool) ?u64 {
|
||||
return paging.translateUserIn(root, virtual, for_write);
|
||||
}
|
||||
|
||||
/// Map a page accessible from ring 3 (U/S bit at every level). The caller keeps
|
||||
/// W^X: code read-only + executable, data writable + no-execute.
|
||||
pub fn mapUserPage(virtual: u64, physical: u64, writable: bool, executable: bool) void {
|
||||
@@ -290,6 +330,20 @@ pub fn userExit() noreturn {
|
||||
user_exit_to_kernel();
|
||||
}
|
||||
|
||||
/// The counter the syscall exit stub bumps when it refuses to return the fast way
|
||||
/// (isr.s, `sysret_non_canonical_count`).
|
||||
const non_canonical_returns = @extern(*u64, .{ .name = "sysret_non_canonical_count" });
|
||||
|
||||
/// How many times this boot's system_call returns took the slow, safe exit because
|
||||
/// the return address was not a canonical address (the SYSRETQ canonical-RIP guard
|
||||
/// in isr.s; an architecture without the hazard reports 0 forever). A correct
|
||||
/// program cannot produce one — it could not have executed at a non-canonical
|
||||
/// address in the first place — so a nonzero count is exactly the number of times
|
||||
/// a process tried to make the kernel fault on its way out.
|
||||
pub fn nonCanonicalReturnCount() u64 {
|
||||
return @atomicLoad(u64, non_canonical_returns, .monotonic);
|
||||
}
|
||||
|
||||
/// Register the handler for the user system_call gate (int 0x80, vector 128). The
|
||||
/// handler may write the trap frame (see `setSystemCallResult`).
|
||||
pub fn setSystemCallHandler(handler: *const fn (*CpuState) void) void {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
//! CPUID — the CPU describing itself.
|
||||
//!
|
||||
//! One helper for the whole architecture layer (the timer's TSC leaves, the
|
||||
//! supervisor-hardening feature bits), rather than a private copy per module.
|
||||
//! `leaf` always executes with ECX = 0, which is what every leaf danos reads
|
||||
//! wants: leaf 7's feature words live in sub-leaf 0, and leaves that ignore ECX
|
||||
//! don't care. A sub-leaf-taking caller would add its own entry point here.
|
||||
//!
|
||||
//! **Always gate on `supports` first.** CPUID does not fault on an out-of-range
|
||||
//! leaf — it returns the data of the highest supported leaf instead, which would
|
||||
//! be read as a feature bit that isn't there. The maximum lives in leaf 0 (basic
|
||||
//! range) and leaf 0x80000000 (extended range).
|
||||
|
||||
pub const Registers = struct { eax: u32, ebx: u32, ecx: u32, edx: u32 };
|
||||
|
||||
/// Execute CPUID for `number` at sub-leaf 0.
|
||||
pub fn leaf(number: u32) Registers {
|
||||
var a: u32 = undefined;
|
||||
var b: u32 = undefined;
|
||||
var c: u32 = undefined;
|
||||
var d: u32 = undefined;
|
||||
asm volatile ("cpuid"
|
||||
: [a] "={eax}" (a),
|
||||
[b] "={ebx}" (b),
|
||||
[c] "={ecx}" (c),
|
||||
[d] "={edx}" (d),
|
||||
: [leaf] "{eax}" (number),
|
||||
[sub] "{ecx}" (@as(u32, 0)),
|
||||
);
|
||||
return .{ .eax = a, .ebx = b, .ecx = c, .edx = d };
|
||||
}
|
||||
|
||||
/// Whether `number` is inside the range this CPU actually enumerates — the basic
|
||||
/// range for a leaf below 0x80000000, the extended range above it. Every read of
|
||||
/// a leaf beyond 0 or 0x80000000 must pass through here first (see the module doc).
|
||||
pub fn supports(number: u32) bool {
|
||||
const maximum = if (number >= 0x8000_0000)
|
||||
leaf(0x8000_0000).eax
|
||||
else
|
||||
leaf(0).eax;
|
||||
return maximum >= number;
|
||||
}
|
||||
@@ -187,11 +187,9 @@ user_exit_to_kernel:
|
||||
# CS/SS from STAR, masks RFLAGS with SFMASK (so IF is already clear), and jumps
|
||||
# here with RSP still the *user* stack. We swap in the kernel GS, switch to the
|
||||
# task's kernel stack via the per-CPU block, build a CpuState frame identical to
|
||||
# the interrupt path's, and reuse interruptDispatch (vector 128) — then SYSRET.
|
||||
#
|
||||
# Hazard (acceptable while init is the only, trusted, user program): SYSRETQ #GPs
|
||||
# in ring 0 if the return RIP (RCX) is non-canonical. A hostile user could arrange
|
||||
# that; hardening (canonical check / iretq fallback) is a later security-track item.
|
||||
# the interrupt path's, and reuse interruptDispatch (vector 128) — then SYSRET,
|
||||
# unless the return RIP is non-canonical, in which case the canonical-RIP guard at
|
||||
# the exit below returns through IRETQ instead (docs/os-development/smep-smap.md).
|
||||
.global syscall_entry
|
||||
syscall_entry:
|
||||
swapgs # kernel GS base
|
||||
@@ -249,16 +247,79 @@ syscall_entry:
|
||||
pop %rax
|
||||
add $16, %rsp # drop vector + error_code -> rsp at rip
|
||||
popq %rcx # rip -> RCX (SYSRETQ restores RIP from RCX)
|
||||
# --- canonical-RIP guard ---
|
||||
# SYSRETQ with a non-canonical RCX raises #GP *in ring 0* on Intel: on the
|
||||
# kernel stack, after the swapgs below has already installed the user's GS
|
||||
# base — a fault in the trusted base, on user-influenced state, which is the
|
||||
# classic escalation primitive (CVE-2012-0217). Ring 3 gets to choose that RIP
|
||||
# without any kernel bug: the CPU saves the address of the instruction *after*
|
||||
# `syscall`, so a program executing `syscall` as the last two bytes of the last
|
||||
# canonical page returns to 0x0000_8000_0000_0000. Nothing between entry and
|
||||
# here rewrites the frame's rip (no signal or context-restore path exists that
|
||||
# could), so this is the whole attack surface — and one compare closes it.
|
||||
#
|
||||
# Canonical means bits 63:47 all equal bit 47, so sign-extending from bit 47
|
||||
# and comparing is the complete test. Bit 47 is the right pivot because danos
|
||||
# is 4-level only: paging.zig builds a PML4 and nothing anywhere sets CR4.LA57
|
||||
# (bit 12), so a linear address is 48-bit on every core, on every machine we
|
||||
# boot. A future 5-level port must pivot on bit 56 instead — and should patch
|
||||
# this shift pair at boot rather than branch on a feature flag, to keep the
|
||||
# return path of every syscall in the system free of loads.
|
||||
#
|
||||
# Cost: four register-only ALU ops and one forward branch the predictor sees
|
||||
# taken exactly never (a correct program cannot have a non-canonical return
|
||||
# address — it could not have executed there). R11 is free scratch: SYSCALL
|
||||
# already destroyed the user's copy, and the fast path overwrites it with the
|
||||
# saved RFLAGS two instructions further on.
|
||||
movq %rcx, %r11
|
||||
shlq $16, %r11
|
||||
sarq $16, %r11 # sign-extend from bit 47
|
||||
cmpq %rcx, %r11 # changed by the round trip => non-canonical
|
||||
jne .Lnon_canonical_return
|
||||
addq $8, %rsp # skip the cs slot (SYSRETQ loads CS from STAR)
|
||||
popq %r11 # rflags -> R11 (SYSRETQ restores RFLAGS from R11)
|
||||
popq %rsp # user rsp (the ss slot below is abandoned)
|
||||
swapgs # user GS base
|
||||
sysretq # -> ring 3: RIP=RCX, RFLAGS=R11, CS/SS from STAR
|
||||
|
||||
# The guard's cold path: return through IRETQ, which is safe where SYSRETQ is not.
|
||||
# IRETQ loads CS — committing the privilege change to ring 3 — before the new RIP
|
||||
# is fetched, so the #GP arrives *from ring 3*: through the IDT, onto this task's
|
||||
# kernel stack, with a user CS in the frame, where isr_common swaps GS back and the
|
||||
# kernel kills the process like any other user fault. That ordering is why IRETQ is
|
||||
# the standard fallback for this exact case; the sysret-canonical test asserts it
|
||||
# on the machine we run on (the process dies, the kernel does not).
|
||||
#
|
||||
# State handed to ring 3 is identical to what the fast path would have produced.
|
||||
# IRETQ consumes the same 5-word frame the CPU pushes for an interrupt — rip, cs,
|
||||
# rflags, rsp, ss — which is exactly the frame syscall_entry built and the fast
|
||||
# path is part-way through dismantling, so un-popping the rip slot makes it whole:
|
||||
# same user RIP, same user RSP, same RFLAGS, and CS/SS = 0x23/0x1B, the very
|
||||
# selectors SYSRETQ would have loaded from STAR. R11 is reloaded from the frame's
|
||||
# rflags slot so even the register SYSRET synthesizes matches. The swapgs sits in
|
||||
# the same place relative to the ring change as the fast path's, so the swapgs
|
||||
# discipline is untouched: kernel GS while we still touch kernel data, user GS for
|
||||
# the instant before ring 3.
|
||||
.Lnon_canonical_return:
|
||||
# Cold-path diagnostic: how many hostile return addresses this boot refused.
|
||||
# `lock` because every core shares the counter, and it costs nothing here — a
|
||||
# process that reaches this line is about to die.
|
||||
lock incq sysret_non_canonical_count(%rip)
|
||||
movq 8(%rsp), %r11 # rflags -> R11, exactly as the fast path leaves it
|
||||
subq $8, %rsp # un-pop the rip slot: rsp back at the iretq frame
|
||||
swapgs # user GS base
|
||||
iretq # -> ring 3, where the bad RIP faults harmlessly
|
||||
|
||||
.section .bss
|
||||
.balign 8
|
||||
user_saved_rsp:
|
||||
.skip 8
|
||||
# Times the canonical-RIP guard above refused a SYSRETQ this boot. Read through the
|
||||
# architecture layer (cpu.zig nonCanonicalReturnCount); zero on any machine no
|
||||
# process has attacked.
|
||||
.global sysret_non_canonical_count
|
||||
sysret_non_canonical_count:
|
||||
.skip 8
|
||||
.text
|
||||
|
||||
# --- user-mode test program --------------------------------------------------
|
||||
@@ -282,6 +343,24 @@ user_pf_start:
|
||||
1: jmp 1b
|
||||
user_pf_end:
|
||||
|
||||
# The SYSRET-guard program: two harmless system calls, the second placed so that
|
||||
# its *return address* is not canonical. The caller (tests.zig) copies these bytes
|
||||
# to the very end of the last canonical user page, so the final `syscall` occupies
|
||||
# the last two bytes of address space ring 3 can execute, and the RIP the CPU saves
|
||||
# into RCX for it is 0x0000_8000_0000_0000 — the first non-canonical address.
|
||||
# The first call proves the ordinary SYSRETQ path still works (the program only
|
||||
# reaches the second instruction pair by returning correctly from the first).
|
||||
# 39 is abi.SystemCall.current_core: no arguments, no side effects, always
|
||||
# succeeds; the test asserts the immediate below still matches that enum.
|
||||
.global user_sysret_start
|
||||
.global user_sysret_end
|
||||
user_sysret_start:
|
||||
mov $39, %eax # current_core
|
||||
syscall # canonical return address (mid-page): the fast path
|
||||
mov $39, %eax # current_core
|
||||
syscall # return address = the end of the page = non-canonical
|
||||
user_sysret_end:
|
||||
|
||||
.text
|
||||
|
||||
# Stub for a vector the CPU does NOT push an error code for: push a dummy 0.
|
||||
@@ -363,7 +442,27 @@ STUB_NOERR 128
|
||||
# If the interrupt came from ring 3 the GS base holds the user's value, so swap
|
||||
# in the kernel's before anything reads per-CPU data (swapgs discipline; see
|
||||
# percpu.zig). CS sits at offset 24 here (vector@0, error@8, RIP@16, CS@24).
|
||||
#
|
||||
# The first three bytes are the SMAP guard, and they come before everything —
|
||||
# before the CPL test, before the swapgs. Interrupt delivery does not clear
|
||||
# EFLAGS.AC (SYSCALL does, through SFMASK; an IDT gate does not), and ring 3 sets
|
||||
# AC freely with popfq, so without this a hostile process could take an interrupt
|
||||
# with AC=1 and have the whole handler run with SMAP suspended. Ahead of the CPL
|
||||
# test because ring 0 inherits AC just as readily: a fault or IRQ nested inside
|
||||
# kernel code carries whatever AC the interrupted context had, and that context
|
||||
# may itself be an entry that has not reached its own guard yet. Clearing first,
|
||||
# unconditionally, means no path into the kernel is ever a path in with AC set.
|
||||
#
|
||||
# `clac` is #UD on a CPU without SMAP, so the image ships the 3-byte canonical NOP
|
||||
# (`nopl (%rax)`) and per-cpu.zig overwrites it with `clac` (0f 01 ca) at boot,
|
||||
# on the boot processor, only when CPUID says the instruction exists — a one-time
|
||||
# patch rather than a branch in the hottest path in the kernel. Neither encoding
|
||||
# touches the flags the `testb` below sets, so the guard is invisible to the code
|
||||
# that follows it either way.
|
||||
.global isr_smap_patch
|
||||
isr_common:
|
||||
isr_smap_patch:
|
||||
.byte 0x0f, 0x1f, 0x00 # nopl (%rax) -> patched to `clac` when the CPU has SMAP
|
||||
testb $3, 24(%rsp)
|
||||
jz 1f
|
||||
swapgs
|
||||
|
||||
@@ -524,6 +524,58 @@ pub fn translateIn(pml4: u64, virtual: u64) ?u64 {
|
||||
return (pte & address_mask) | (virtual & (page_size - 1));
|
||||
}
|
||||
|
||||
/// `translateIn` with the ring-3 permission bits enforced: the walk accumulates
|
||||
/// the protection flags of every level it descends through and refuses the
|
||||
/// translation unless the *effective* permission allows the access ring 3 would
|
||||
/// be allowed — U/S set at every level, and (for `for_write`) R/W set at every
|
||||
/// level too. A bit cleared anywhere on the path denies, which is exactly how
|
||||
/// the MMU combines them, so a checked kernel copy sees the same permissions the
|
||||
/// process itself does.
|
||||
///
|
||||
/// This is the walk `system/kernel/user-memory.zig` copies through, and the
|
||||
/// reason a kernel copy can never be steered at a kernel-only mapping or made to
|
||||
/// write a read-only user page (a process's own text, say).
|
||||
///
|
||||
/// Huge pages: a 2 MiB PDE leaf resolves like `translateIn`, with its own U/S and
|
||||
/// R/W folded into the accumulator first. A PDPTE with PS set (a 1 GiB leaf) is
|
||||
/// refused rather than descended into — danos never builds one, and denying is
|
||||
/// the safe direction for a permission-checked walk.
|
||||
pub fn translateUserIn(pml4: u64, virtual: u64, for_write: bool) ?u64 {
|
||||
// Start all-ones and AND in each level: a cleared bit at any level denies.
|
||||
var effective: u64 = ~@as(u64, 0);
|
||||
|
||||
const pml4e = tableAt(pml4)[(virtual >> 39) & 0x1FF];
|
||||
if (pml4e & present == 0) return null;
|
||||
effective &= pml4e;
|
||||
|
||||
const pdpte = tableAt(pml4e & address_mask)[(virtual >> 30) & 0x1FF];
|
||||
if (pdpte & present == 0) return null;
|
||||
if (pdpte & page_size_bit != 0) return null; // 1 GiB leaf: never built here, refuse
|
||||
effective &= pdpte;
|
||||
|
||||
const pde = tableAt(pdpte & address_mask)[(virtual >> 21) & 0x1FF];
|
||||
if (pde & present == 0) return null;
|
||||
effective &= pde;
|
||||
if (pde & page_size_bit != 0) { // 2 MiB huge leaf: frame base is bits 51:21
|
||||
if (!permits(effective, for_write)) return null;
|
||||
return (pde & address_mask & ~@as(u64, huge_page_size - 1)) | (virtual & (huge_page_size - 1));
|
||||
}
|
||||
|
||||
const pte = tableAt(pde & address_mask)[(virtual >> 12) & 0x1FF];
|
||||
if (pte & present == 0) return null;
|
||||
effective &= pte;
|
||||
if (!permits(effective, for_write)) return null;
|
||||
return (pte & address_mask) | (virtual & (page_size - 1));
|
||||
}
|
||||
|
||||
/// Whether accumulated walk flags allow a ring-3 access: user-accessible always,
|
||||
/// and writable when the access is a store.
|
||||
fn permits(effective: u64, for_write: bool) bool {
|
||||
if (effective & user == 0) return false;
|
||||
if (for_write and effective & writable == 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn invalidate(virtual: u64) void {
|
||||
// invlpg needs its operand via a register-indirect memory reference that Zig
|
||||
// inline asm won't form directly, so stage the address in a register first.
|
||||
|
||||
@@ -11,9 +11,24 @@
|
||||
//! transition is always an exit (the kernel starts in ring 0), the swap pairs
|
||||
//! keep the invariant without seeding KERNEL_GS_BASE. `scheduler()` is therefore
|
||||
//! valid in any ring-0 context and never sees a user-controlled base.
|
||||
//!
|
||||
//! The file has since become the home of **per-core CPU state set at bring-up**
|
||||
//! generally, not just the GS block: the fast-system_call MSRs and the CR4
|
||||
//! hardening bits live here too, because each is state a core owns and must set
|
||||
//! for itself. Both bring-up paths — `cpu.init` on the boot processor and
|
||||
//! `smp.apEntry` on every application processor — call the same functions here.
|
||||
//!
|
||||
//! One deliberate exception to "per-core": `armSupervisorAccessPrevention` patches
|
||||
//! a machine-wide instruction into the shared interrupt entry, once, on the boot
|
||||
//! processor. It lives here anyway because it is the other half of CR4.SMAP —
|
||||
//! same feature probe, same fail-open posture — and splitting a hardening measure
|
||||
//! across two files is how the halves drift apart.
|
||||
|
||||
const std = @import("std");
|
||||
const io = @import("io.zig");
|
||||
const cpuid = @import("cpuid.zig");
|
||||
const paging = @import("paging.zig");
|
||||
const boot_handoff = @import("boot-handoff");
|
||||
const parameters = @import("parameters");
|
||||
|
||||
const ia32_gs_base = 0xC000_0101;
|
||||
@@ -73,5 +88,171 @@ pub fn initSystemCall() void {
|
||||
io.wrmsr(ia32_star, (@as(u64, 0x08) << 32) | (@as(u64, 0x10) << 48));
|
||||
const entry = @extern(*const anyopaque, .{ .name = "syscall_entry" });
|
||||
io.wrmsr(ia32_lstar, @intFromPtr(entry));
|
||||
io.wrmsr(ia32_sfmask, 0x4_0700); // clear IF, TF, DF, AC on entry
|
||||
// Clear IF, TF, DF, AC and NT on entry. The first four are the usual
|
||||
// hygiene; NT is here because SYSCALL, unlike an interrupt gate, does not
|
||||
// clear it for us, so without this the kernel runs every system call with
|
||||
// whatever nested-task bit ring 3 last chose — and the canonical-RIP guard's
|
||||
// cold path (isr.s) leaves through IRETQ, whose behaviour with NT set is a
|
||||
// corner of the manuals not worth depending on either way. Masking it costs
|
||||
// one bit and removes the question: the kernel is never nested, and ring 3
|
||||
// still gets its own NT back, from R11 on the fast path and from the frame
|
||||
// on the cold one.
|
||||
io.wrmsr(ia32_sfmask, 0x4_4700);
|
||||
}
|
||||
|
||||
// --- supervisor-mode hardening (CR4) ---------------------------------------
|
||||
//
|
||||
// CR4 is per-core state, so these bits are set during *every* core's bring-up —
|
||||
// the BSP in cpu.init, each AP in smp.apEntry — and not in the AP trampoline,
|
||||
// which stays minimal and would only cover the APs anyway.
|
||||
|
||||
/// CR4.SMEP: an instruction fetch in ring 0 from a page whose U/S bit says *user*
|
||||
/// raises #PF. This is what makes the classic ret2usr shape (a kernel bug steered
|
||||
/// into attacker-prepared user code) a loud, attributable fault instead of a
|
||||
/// silent compromise. danos never executes user-mapped memory in ring 0 — kernel
|
||||
/// text lives in the higher half, the ring-3 entry paths are kernel code, and the
|
||||
/// AP trampoline page is a supervisor mapping — so nothing legitimate is refused.
|
||||
const cr4_smep: u64 = 1 << 20;
|
||||
|
||||
/// SMEP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 7.
|
||||
fn smepSupported() bool {
|
||||
if (!cpuid.supports(7)) return false;
|
||||
return cpuid.leaf(7).ebx & (1 << 7) != 0;
|
||||
}
|
||||
|
||||
/// CR4.SMAP: a ring-0 data *read or write* to a page whose U/S bit says *user*
|
||||
/// raises #PF, unless EFLAGS.AC is set. It is the standing enforcement behind
|
||||
/// system/kernel/user-memory.zig: that layer never dereferences a user virtual
|
||||
/// address — it walks the process's tables and moves bytes through the physmap,
|
||||
/// kernel mappings throughout — so nothing legitimate in this kernel is refused,
|
||||
/// and any future code that reaches for a user pointer directly faults the first
|
||||
/// time it runs. danos therefore opens no `stac` window anywhere; there is no
|
||||
/// correct reason to have one, and adding one is how the guarantee is lost.
|
||||
const cr4_smap: u64 = 1 << 21;
|
||||
|
||||
/// SMAP's feature bit: CPUID leaf 7, sub-leaf 0, EBX bit 20.
|
||||
fn smapSupported() bool {
|
||||
if (!cpuid.supports(7)) return false;
|
||||
return cpuid.leaf(7).ebx & (1 << 20) != 0;
|
||||
}
|
||||
|
||||
/// `clac` — the three bytes that replace the NOP at `isr_smap_patch` once the
|
||||
/// interrupt entry is allowed to execute them.
|
||||
const clac_opcode = [_]u8{ 0x0f, 0x01, 0xca };
|
||||
|
||||
/// Set only once the interrupt entry really clears AC, and read by every core
|
||||
/// before it turns SMAP on. Nothing turns SMAP on until this is true, so there is
|
||||
/// no window — not even on the boot processor, not even before ring 3 exists —
|
||||
/// in which the bit is live while an interrupt could still be taken with AC set.
|
||||
var interrupt_entry_clears_ac = false;
|
||||
|
||||
fn readCr3() u64 {
|
||||
return asm volatile ("mov %%cr3, %[out]"
|
||||
: [out] "=r" (-> u64),
|
||||
);
|
||||
}
|
||||
|
||||
/// Patch the `clac` into the shared interrupt entry, and by doing so authorize
|
||||
/// CR4.SMAP. **Boot processor only, once, before any core sets the bit and before
|
||||
/// the application processors are woken** — `initHardening` refuses SMAP until
|
||||
/// this has run, so the order is enforced rather than merely documented, and an AP
|
||||
/// climbing the trampoline cannot get ahead of it.
|
||||
///
|
||||
/// The write goes through the physmap, not through the kernel's own view of its
|
||||
/// text: once `paging.init` has run, kernel `.text` is mapped read-only under W^X,
|
||||
/// and a store to it would fault (or, worse, silently need CR0.WP cleared). The
|
||||
/// physmap alias of the same frame is an ordinary supervisor RW mapping — the same
|
||||
/// door `smp.arm` uses to write the AP trampoline and `process.run` uses to fill a
|
||||
/// read-only user code frame. Going through it also makes this correct under
|
||||
/// *either* set of tables: the loader's bootstrap tables map the image writable,
|
||||
/// the kernel's own do not, and this runs before the switch.
|
||||
///
|
||||
/// Three bytes, translated one at a time, so a patch site that straddles a page
|
||||
/// boundary is not a special case. A translation that fails leaves the NOP in
|
||||
/// place and returns false, and the machine then boots without SMAP rather than
|
||||
/// with SMAP and an entry path that cannot clear AC.
|
||||
pub fn armSupervisorAccessPrevention() bool {
|
||||
if (!smapSupported()) return false;
|
||||
const site = @intFromPtr(@extern([*]const u8, .{ .name = "isr_smap_patch" }));
|
||||
const root = readCr3() & 0x000F_FFFF_FFFF_F000;
|
||||
|
||||
// MUST run with interrupts masked, and does: this is reached from cpu.init,
|
||||
// long before the kernel's `sti`, and before any application processor exists.
|
||||
// The reason is that the three bytes go in one at a time, and the middle state
|
||||
// — 0f 01 00, once the second byte lands — is `sgdt (%rax)`, a ten-byte write
|
||||
// to wherever RAX points, sitting at the first instruction of every interrupt
|
||||
// entry. Nothing can take that entry here, so nothing can execute it. A future
|
||||
// change that moves this after interrupts are enabled has to close that window
|
||||
// first (one 16-bit store covering both changed bytes is the shape, but it
|
||||
// needs the two to be physically contiguous and 2-byte aligned — a naive
|
||||
// version of exactly that triple-faulted this kernel).
|
||||
for (clac_opcode, 0..) |byte, i| {
|
||||
const physical = paging.translateIn(root, site + i) orelse return false;
|
||||
const alias: *volatile u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
|
||||
alias.* = byte;
|
||||
}
|
||||
// The bytes were written through a different linear address than the one they
|
||||
// will be fetched from, so serialize before anyone can execute them: CPUID is
|
||||
// the architecturally sanctioned way to discard whatever the core prefetched or
|
||||
// decoded of the old encoding.
|
||||
_ = cpuid.leaf(0);
|
||||
// Read back through the *text* address, not the alias just written: that is the
|
||||
// view the CPU will fetch from, so this is what proves the two are the same
|
||||
// physical page and the patch landed where it will actually execute. A mismatch
|
||||
// means the translation lied, and SMAP stays off rather than being enabled over
|
||||
// an entry path that cannot clear AC.
|
||||
const installed: [*]const volatile u8 = @ptrFromInt(site);
|
||||
for (clac_opcode, 0..) |byte, i| {
|
||||
if (installed[i] != byte) return false;
|
||||
}
|
||||
interrupt_entry_clears_ac = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
fn readCr4() u64 {
|
||||
return asm volatile ("mov %%cr4, %[out]"
|
||||
: [out] "=r" (-> u64),
|
||||
);
|
||||
}
|
||||
|
||||
fn writeCr4(value: u64) void {
|
||||
asm volatile ("mov %[in], %%cr4"
|
||||
:
|
||||
: [in] "r" (value),
|
||||
: .{ .memory = true });
|
||||
}
|
||||
|
||||
/// Turn on the supervisor-mode hardening this CPU offers, on the calling core.
|
||||
/// Called once per core, next to `initSystemCall`, from both bring-up paths.
|
||||
///
|
||||
/// **Fail-open, like the IOMMU and the clocksource:** an absent feature is a
|
||||
/// machine that boots unhardened, not a machine that refuses to boot. danos has
|
||||
/// to run on any VM, on real Intel and on real AMD; the boot log states the
|
||||
/// posture either way (see the platform block in system/kernel/kernel.zig), so
|
||||
/// an unhardened boot is visible rather than assumed.
|
||||
pub fn initHardening() void {
|
||||
var bits: u64 = 0;
|
||||
if (smepSupported()) bits |= cr4_smep;
|
||||
// SMAP only after the boot processor has armed the interrupt entry: with the
|
||||
// NOP still in place an interrupt inherits ring 3's AC and suspends SMAP for
|
||||
// the length of the handler, which is worse than not claiming the bit at all.
|
||||
if (smapSupported() and interrupt_entry_clears_ac) bits |= cr4_smap;
|
||||
if (bits == 0) return;
|
||||
writeCr4(readCr4() | bits);
|
||||
}
|
||||
|
||||
/// Whether supervisor-mode execution prevention is live on *this* core. Read
|
||||
/// straight out of CR4 rather than a remembered probe result, so the answer is
|
||||
/// the state the hardware is actually in — which is what both the boot log and
|
||||
/// the `fault-smep` test case want to assert.
|
||||
pub fn supervisorExecutePreventionEnabled() bool {
|
||||
return readCr4() & cr4_smep != 0;
|
||||
}
|
||||
|
||||
/// Whether supervisor-mode *access* prevention is live on *this* core. Read from
|
||||
/// CR4 for the same reason as its neighbour: the question the boot log and the
|
||||
/// `fault-smap` case are asking is what the hardware is doing, not what a probe
|
||||
/// once concluded.
|
||||
pub fn supervisorAccessPreventionEnabled() bool {
|
||||
return readCr4() & cr4_smap != 0;
|
||||
}
|
||||
|
||||
@@ -179,6 +179,13 @@ fn apEntry(percpu: usize) callconv(.c) noreturn {
|
||||
idt.loadOnThisCpu(); // the shared IDT
|
||||
pcpu.setLocal(cpu, percpu); // per-CPU block via GS base — *after* the GDT reload
|
||||
pcpu.initSystemCall(); // enable system_call/sysret on this core
|
||||
// CR4 is per-core: this core starts from the trampoline's CR4 (PAE + SSE only),
|
||||
// so it sets its own hardening bits here rather than in the trampoline — one
|
||||
// Zig code path shared with the BSP (cpu.init), and the trampoline stays minimal.
|
||||
// CR4.SMEP and CR4.SMAP on this core, if the CPU has them. The `clac` the SMAP
|
||||
// bit depends on was patched into the shared interrupt entry by the BSP long
|
||||
// before this core was woken, so an AP only ever finds it already armed.
|
||||
pcpu.initHardening();
|
||||
|
||||
apic.initSecondary(); // software-enable this core's LAPIC
|
||||
apic.initTimer(apic.frequencyHz()); // arm its timer (still masked: interrupts off)
|
||||
|
||||
@@ -132,13 +132,31 @@ fn record(node: *platform.Device, parent_id: u64) u64 {
|
||||
}
|
||||
|
||||
/// Copy up to `out.len` device descriptors into `out`; returns the total count
|
||||
/// available (which may exceed `out.len`).
|
||||
/// available (which may exceed `out.len`). For kernel callers with a buffer big
|
||||
/// enough to take the whole table in one go.
|
||||
pub fn enumerate(out: []device_abi.DeviceDescriptor) usize {
|
||||
const n = @min(count, out.len);
|
||||
@memcpy(out[0..n], devices[0..n]);
|
||||
_ = enumerateFrom(0, out);
|
||||
return count;
|
||||
}
|
||||
|
||||
/// How many devices the table holds — the total `device_enumerate` reports back
|
||||
/// however few of them fit in the caller's buffer.
|
||||
pub fn deviceCount() usize {
|
||||
return count;
|
||||
}
|
||||
|
||||
/// Copy up to `out.len` descriptors starting at table index `start`, returning how
|
||||
/// many were filled (0 once `start` reaches the end). The chunked form: the
|
||||
/// `device_enumerate` system call bounces the table out through a small kernel
|
||||
/// buffer, one chunk at a time, because a descriptor is far too big to stage a
|
||||
/// whole user-requested array of them on a 16 KiB kernel stack.
|
||||
pub fn enumerateFrom(start: usize, out: []device_abi.DeviceDescriptor) usize {
|
||||
if (start >= count) return 0;
|
||||
const n = @min(count - start, out.len);
|
||||
@memcpy(out[0..n], devices[start..][0..n]);
|
||||
return n;
|
||||
}
|
||||
|
||||
/// Take exclusive ownership of device `id` for task `owner`. Fails if the id is
|
||||
/// out of range or already claimed.
|
||||
pub fn claim(id: u64, owner: u32) bool {
|
||||
|
||||
@@ -17,18 +17,20 @@
|
||||
//! endpoint's own FIFO (threaded through the otherwise-idle `Task.next`); servers
|
||||
//! waiting for work use a normal WaitQueue.
|
||||
//!
|
||||
//! Trust model (bring-up): copies honour only page presence and a user-half bound,
|
||||
//! not the leaf U/S or R/W bits and not SMAP — a #PF-tolerant, permission-checked
|
||||
//! copy is a later security-track item, matching the existing debug_write gap.
|
||||
//! Trust model: every side of a copy that names a *user* address space goes
|
||||
//! through system/kernel/user-memory.zig — user-half bound, page presence, and
|
||||
//! the leaf permissions ring 3 itself would face (U/S to read, U/S + R/W to
|
||||
//! write). A kernel-side buffer is trusted and translated as-is. An unmapped or
|
||||
//! wrongly-permissioned page fails the operation; it never faults ring 0.
|
||||
|
||||
const std = @import("std");
|
||||
const boot_handoff = @import("boot-handoff");
|
||||
const abi = @import("abi");
|
||||
const architecture = @import("architecture");
|
||||
const scheduler = @import("scheduler.zig");
|
||||
const sync = @import("sync.zig");
|
||||
const heap = @import("heap.zig");
|
||||
const pmm = @import("pmm.zig");
|
||||
const user_memory = @import("user-memory.zig");
|
||||
|
||||
const page_size = abi.page_size;
|
||||
const Task = scheduler.Task;
|
||||
@@ -38,16 +40,13 @@ const Task = scheduler.Task;
|
||||
pub const MESSAGE_MAXIMUM: usize = 256;
|
||||
|
||||
pub const maximum_handles = scheduler.ipc_maximum_handles;
|
||||
// The name registry is indexed directly by ServiceId, so this must exceed the
|
||||
// largest id (currently fat = 8). Sized with headroom for new services.
|
||||
pub const maximum_services = 16;
|
||||
|
||||
/// Errno-style failures, returned as `-value` in the system_call result register.
|
||||
pub const EBADF: i64 = 1; // bad handle
|
||||
pub const E2BIG: i64 = 2; // message exceeds MESSAGE_MAXIMUM
|
||||
pub const EFAULT: i64 = 3; // buffer unmapped / out of the user half
|
||||
pub const ENOENT: i64 = 4; // no such registered service
|
||||
pub const ENOSPC: i64 = 5; // handle table or registry full
|
||||
pub const ENOENT: i64 = 4; // no such name
|
||||
pub const ENOSPC: i64 = 5; // handle table full
|
||||
pub const ENOMEM: i64 = 6; // out of memory
|
||||
pub const EPEER: i64 = 7; // peer died before replying (its process exited or was killed)
|
||||
pub const ESRCH: i64 = 8; // no such process (process_kill of an unknown/dead id)
|
||||
@@ -83,16 +82,26 @@ const PostSlot = struct {
|
||||
bytes: [POST_MAXIMUM]u8 = undefined,
|
||||
};
|
||||
|
||||
/// End of the user (low) canonical half — user buffers must lie below it.
|
||||
const user_half_end: u64 = 0x0000_8000_0000_0000;
|
||||
/// End of the user (low) canonical half — user buffers must lie below it. One
|
||||
/// definition, in the module that owns the user-memory contract.
|
||||
const user_half_end: u64 = user_memory.user_half_end;
|
||||
|
||||
/// A rendezvous endpoint. Allocated from the kernel heap; referenced by handle
|
||||
/// (per process) and/or by a registry slot, counted by `refcount`.
|
||||
/// (per process) and by whoever a capability was passed to, counted by `refcount`.
|
||||
pub const Endpoint = struct {
|
||||
refcount: u32 = 1,
|
||||
/// Next in the list of every live endpoint. Endpoints are otherwise reachable
|
||||
/// only through the handle tables that name them, and the death path has to
|
||||
/// find a dying task's endpoints without one — see `live_endpoints`.
|
||||
next_live: ?*Endpoint = null,
|
||||
// The task that created it. When that task dies, the endpoint is marked `dead` so a caller
|
||||
// gets -EPEER instead of blocking forever on a service that will never reply again (V6).
|
||||
owner: u32 = 0,
|
||||
// The *process* that created it — `owner`'s leader, snapshotted at creation so the
|
||||
// answer survives the creating thread. `owner` alone cannot answer "is this mine?"
|
||||
// for a threaded service, and the question has to be answerable after that thread is
|
||||
// gone; see `ownedBy`.
|
||||
owner_leader: u32 = 0,
|
||||
dead: bool = false,
|
||||
// Callers blocked in `call`, awaiting receive, in FIFO order (threaded via
|
||||
// Task.next; each such task is .blocked and in no scheduler queue).
|
||||
@@ -112,29 +121,78 @@ pub const Endpoint = struct {
|
||||
post_tail: u16 = 0,
|
||||
};
|
||||
|
||||
/// Every live endpoint, singly linked through `next_live`. The list exists for
|
||||
/// exactly one purpose: the death path must mark a dying task's endpoints dead,
|
||||
/// and a handle table only answers the other question (which endpoints does this
|
||||
/// task *hold*). Mutated under the big kernel lock, like every other IPC global.
|
||||
var live_endpoints: ?*Endpoint = null;
|
||||
|
||||
pub fn createIpcEndpoint() ?*Endpoint {
|
||||
const creator = scheduler.current();
|
||||
const endpoint = heap.allocator().create(Endpoint) catch return null;
|
||||
endpoint.* = .{ .owner = scheduler.currentId() };
|
||||
endpoint.* = .{ .owner = creator.id, .owner_leader = creator.leader, .next_live = live_endpoints };
|
||||
live_endpoints = endpoint;
|
||||
return endpoint;
|
||||
}
|
||||
|
||||
/// A task is dying: kill the endpoints it registered as services. Mark each `dead` (so a later
|
||||
/// `call` returns -EPEER rather than blocking on a reply that will never come), wake anyone
|
||||
/// already parked sending to it with that error, and vacate its registry slot. Only *registered*
|
||||
/// endpoints are reachable from here; unregistered ones drop with the task's handle table. The
|
||||
/// caller holds the big kernel lock (this runs on the death path). See docs/display-v2.md (V6).
|
||||
/// Whether `t` may have the kernel post **notifications** — signals, timer
|
||||
/// landings, exit notices, interrupts — into `endpoint`: whether the endpoint is
|
||||
/// its process's own.
|
||||
///
|
||||
/// Holding a *handle* to an endpoint is not ownership of it. `fs_resolve`
|
||||
/// installs a mounted backend's capability in any caller's table
|
||||
/// (`installHandleDeduped`), and any capability may be passed along a call, so a
|
||||
/// sendable handle means only "you may talk to this". A kernel notification is
|
||||
/// different in kind: it makes the kernel speak *into* someone else's mailbox
|
||||
/// with a badge that receiver cannot distinguish from one it asked for — a
|
||||
/// genuine signal badge, a genuine timer landing. That is how a forged
|
||||
/// `terminate` reached PID 1's shutdown path: the attacker aimed **its own**
|
||||
/// signal delivery at init's endpoint with `signal_bind` and then signalled
|
||||
/// itself, and every bit the kernel stamped was authentic. Refusing the *bind*
|
||||
/// is the only place the distinction still exists.
|
||||
///
|
||||
/// Threads: ownership is the **process's**, not the task's, so any thread may
|
||||
/// bind an endpoint a sibling created — the same normalization `process_signal`
|
||||
/// and `process_kill` perform when they resolve a member to its leader. The
|
||||
/// creating task's own id is honoured too, which is what keeps kernel tasks
|
||||
/// (leader 0) from being treated as one process.
|
||||
pub fn ownedBy(endpoint: *const Endpoint, t: *const Task) bool {
|
||||
if (endpoint.owner == t.id) return true;
|
||||
return t.leader != 0 and endpoint.owner_leader == t.leader;
|
||||
}
|
||||
|
||||
/// Unlink a freed endpoint from the live list. O(n) in the number of live
|
||||
/// endpoints, which is tens.
|
||||
fn forgetEndpoint(endpoint: *Endpoint) void {
|
||||
var link = &live_endpoints;
|
||||
while (link.*) |current| {
|
||||
if (current == endpoint) {
|
||||
link.* = current.next_live;
|
||||
return;
|
||||
}
|
||||
link = ¤t.next_live;
|
||||
}
|
||||
}
|
||||
|
||||
/// A task is dying: kill every endpoint it created. Mark each `dead` (so a later
|
||||
/// `call` returns -EPEER rather than blocking on a reply that will never come) and wake
|
||||
/// anyone already parked sending to it with that error. This is what makes a provider's
|
||||
/// death visible to the clients holding its capability — the naming layer's restart
|
||||
/// story (a client re-resolves on -EPEER) rests on it, as does the VFS router's lazy
|
||||
/// unmount of a backend that died. The endpoint object itself lives until the last
|
||||
/// handle naming it drops. The caller holds the big kernel lock (this runs on the death
|
||||
/// path). See docs/display-v2.md (V6).
|
||||
pub fn killOwnedEndpointsLocked(task_id: u32) void {
|
||||
for (®istry) |*slot| {
|
||||
const endpoint = slot.* orelse continue;
|
||||
if (endpoint.owner != task_id) continue;
|
||||
var current = live_endpoints;
|
||||
while (current) |endpoint| {
|
||||
current = endpoint.next_live;
|
||||
if (endpoint.owner != task_id or endpoint.dead) continue;
|
||||
endpoint.dead = true;
|
||||
while (dequeueSender(endpoint)) |sender| {
|
||||
sender.ipc_status = -EPEER;
|
||||
sender.ipc_received_cap = abi.no_cap;
|
||||
scheduler.readyLocked(sender);
|
||||
}
|
||||
slot.* = null;
|
||||
dropRef(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +202,7 @@ pub fn dropRef(endpoint: *Endpoint) void {
|
||||
if (endpoint.refcount > 1) {
|
||||
endpoint.refcount -= 1;
|
||||
} else {
|
||||
forgetEndpoint(endpoint);
|
||||
heap.allocator().destroy(endpoint);
|
||||
}
|
||||
}
|
||||
@@ -300,18 +359,22 @@ pub fn abandonSenderLocked(t: *Task) void {
|
||||
/// Copy `len` bytes from `source_va` in address space `source_as` to `destination_va` in
|
||||
/// `destination_as`, walking each side's page tables through the physmap (no CR3 switch).
|
||||
/// `*_as == 0` means the kernel address space (for kernel-task endpoints). User
|
||||
/// buffers must lie in the low half. Returns false — never #PFs — if any page is
|
||||
/// unmapped or out of range. Handles page-straddling buffers.
|
||||
/// buffers must lie in the low half and carry the permission ring 3 would need for
|
||||
/// their side of the copy — readable to send from, writable to receive into.
|
||||
/// Returns false — never #PFs — if any page is unmapped, out of range, or
|
||||
/// wrongly permissioned. Handles page-straddling buffers.
|
||||
///
|
||||
/// This is the process↔process case, which `user-memory` deliberately does not
|
||||
/// cover (it knows one user address space at a time); both sides resolve through
|
||||
/// `user_memory.resolve`, so the permission rules are the same ones.
|
||||
fn copyAcross(source_as: u64, source_va: u64, destination_as: u64, destination_va: u64, len: usize) bool {
|
||||
const source_root = if (source_as != 0) source_as else architecture.kernelPageTable();
|
||||
const destination_root = if (destination_as != 0) destination_as else architecture.kernelPageTable();
|
||||
if (source_as != 0 and (source_va >= user_half_end or source_va + len > user_half_end)) return false;
|
||||
if (destination_as != 0 and (destination_va >= user_half_end or destination_va + len > user_half_end)) return false;
|
||||
if (source_as != 0 and !user_memory.userRangeOk(source_va, len)) return false;
|
||||
if (destination_as != 0 and !user_memory.userRangeOk(destination_va, len)) return false;
|
||||
|
||||
var off: usize = 0;
|
||||
while (off < len) {
|
||||
const s = architecture.translate(source_root, source_va + off) orelse return false;
|
||||
const d = architecture.translate(destination_root, destination_va + off) orelse return false;
|
||||
const s = user_memory.resolve(source_as, source_va + off, false) orelse return false;
|
||||
const d = user_memory.resolve(destination_as, destination_va + off, true) orelse return false;
|
||||
const s_left = page_size - ((source_va + off) & (page_size - 1));
|
||||
const d_left = page_size - ((destination_va + off) & (page_size - 1));
|
||||
const n = @min(@min(s_left, d_left), len - off);
|
||||
@@ -323,27 +386,10 @@ fn copyAcross(source_as: u64, source_va: u64, destination_as: u64, destination_v
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Copy `destination.len` bytes from `user_va` in address space `user_as` into the kernel
|
||||
/// buffer `destination`, walking the user page tables through the physmap. Returns false if
|
||||
/// the range escapes the user half or any source page is unmapped — so a bad user
|
||||
/// pointer *fails the system_call* rather than faulting the kernel (danos has no
|
||||
/// fault-recovering copy-in, so a raw dereference of an unmapped user page would halt
|
||||
/// the machine). The correct way to pull a fixed-size struct in from user space, and
|
||||
/// a single fetch: no TOCTOU against a hostile pointer.
|
||||
pub fn copyFromUser(user_as: u64, user_va: u64, destination: []u8) bool {
|
||||
if (user_as == 0) return false; // not a user address space
|
||||
if (user_va >= user_half_end or user_va + destination.len > user_half_end) return false;
|
||||
var off: usize = 0;
|
||||
while (off < destination.len) {
|
||||
const s = architecture.translate(user_as, user_va + off) orelse return false;
|
||||
const s_left = page_size - ((user_va + off) & (page_size - 1));
|
||||
const n = @min(s_left, destination.len - off);
|
||||
const source: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(s));
|
||||
@memcpy(destination[off..][0..n], source[0..n]);
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
/// The checked copy-in, re-exported from its home in `user-memory` so the many
|
||||
/// `ipc.copyFromUser` call sites keep reading naturally. New code should reach
|
||||
/// for `user-memory` directly — it is where the write direction lives too.
|
||||
pub const copyFromUser = user_memory.copyFromUser;
|
||||
|
||||
// --- the two IPC operations -------------------------------------------------
|
||||
|
||||
@@ -643,22 +689,9 @@ fn dropEntry(entry: scheduler.HandleObject) void {
|
||||
}
|
||||
}
|
||||
|
||||
var registry: [maximum_services]?*Endpoint = .{null} ** maximum_services;
|
||||
|
||||
/// Publish `endpoint` under well-known `id` (takes a reference). Returns 0 or -errno.
|
||||
pub fn register(id: u32, endpoint: *Endpoint) i64 {
|
||||
if (id >= maximum_services) return -ENOENT;
|
||||
if (registry[id]) |old| dropRef(old);
|
||||
endpoint.refcount += 1;
|
||||
registry[id] = endpoint;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Find the endpoint published under `id`, taking a reference for the caller to
|
||||
/// install in its handle table. Null if nothing is registered there.
|
||||
pub fn lookup(id: u32) ?*Endpoint {
|
||||
if (id >= maximum_services) return null;
|
||||
const endpoint = registry[id] orelse return null;
|
||||
endpoint.refcount += 1;
|
||||
return endpoint;
|
||||
}
|
||||
// The flat `ServiceId` registry lived here — a 16-slot table any process could
|
||||
// write, indexed by a compile-time enum. Naming is user-space's job now: init
|
||||
// serves `/protocol` and decides who may claim a name
|
||||
// (docs/os-development/protocol-namespace.md). The kernel keeps only what is
|
||||
// genuinely kernel work — moving capabilities and telling clients their provider
|
||||
// died (`killOwnedEndpointsLocked`).
|
||||
|
||||
@@ -47,8 +47,8 @@ pub const maximum_gsi = 24;
|
||||
var bound: [maximum_gsi]?*ipc_sync.Endpoint = .{null} ** maximum_gsi;
|
||||
|
||||
/// Task that owns each binding. Teardown is keyed on *this*, not on the endpoint
|
||||
/// pointer: an endpoint can be shared between processes (ipc_register/ipc_lookup hand
|
||||
/// out extra references), so "every GSI pointing at this endpoint" is not the same
|
||||
/// pointer: an endpoint can be shared between processes (a capability passed in a message
|
||||
/// hands out extra references), so "every GSI pointing at this endpoint" is not the same
|
||||
/// set as "every GSI this process bound", and releasing the former on exit would mask
|
||||
/// a live sibling's device line.
|
||||
var bound_owner: [maximum_gsi]u32 = .{0} ** maximum_gsi;
|
||||
|
||||
@@ -279,6 +279,22 @@ fn kmain(boot_information: *const BootInformation) noreturn {
|
||||
log.print(" cpus : {d} usable core(s); 1 running (BSP), {d} AP(s) parked (SMP bring-up pending)\n", .{ cores.len, if (cores.len > 0) cores.len - 1 else 0 });
|
||||
if (platform.cpusDropped() > 0)
|
||||
log.print(" cpus : WARNING {d} core(s) beyond pool cap dropped\n", .{platform.cpusDropped()});
|
||||
// The supervisor-execution posture, stated plainly at every boot. Every core
|
||||
// sets the bit during its own bring-up (architecture/x86_64/per-cpu.zig); the
|
||||
// BSP answers for the machine here, because the feature is a property of the
|
||||
// CPU model, not of an individual core. Fail-open like the IOMMU: a CPU without
|
||||
// it still boots, it just boots unhardened, and says so.
|
||||
if (architecture.supervisorExecutePreventionEnabled())
|
||||
log.write(" smep : enabled - ring 0 cannot execute user pages\n")
|
||||
else
|
||||
log.write(" smep : absent - ring-0 execution of user pages unprevented\n");
|
||||
// The supervisor-access posture, the same way. Its second half is a boot-time
|
||||
// patch (the `clac` at the interrupt entry), so "absent" here covers both a
|
||||
// CPU without the feature and a patch that could not be applied.
|
||||
if (architecture.supervisorAccessPreventionEnabled())
|
||||
log.write(" smap : enabled - ring 0 reaches user memory only through the checked copy layer\n")
|
||||
else
|
||||
log.write(" smap : absent - ring-0 access to user pages unprevented\n");
|
||||
// The DMA-isolation posture, stated plainly at every boot. When a unit exists,
|
||||
// iommu.init already logged its enable block above; here we only state the
|
||||
// fail-open case, so a boot without the line is a boot with translation on.
|
||||
|
||||
@@ -161,7 +161,7 @@ test "append/read round trip" {
|
||||
defer std.testing.allocator.destroy(ring);
|
||||
ring.* = .{};
|
||||
|
||||
_ = ring.append(7, "/system/services/fat", .info, 123, "mounted /mnt/usb", false);
|
||||
_ = ring.append(7, "/system/services/fat", .info, 123, "mounted /volumes/usb", false);
|
||||
_ = ring.append(0, "kernel", .raw, 456, "wall clock online", false);
|
||||
|
||||
const first = parseAt(ring, ring.tail);
|
||||
@@ -169,7 +169,7 @@ test "append/read round trip" {
|
||||
try std.testing.expectEqual(abi.KlogLevel.info, first.header.level);
|
||||
try std.testing.expectEqual(@as(u64, 123), first.header.timestamp_ns);
|
||||
try std.testing.expectEqualStrings("/system/services/fat", first.nameSlice());
|
||||
try std.testing.expectEqualStrings("mounted /mnt/usb", first.messageSlice());
|
||||
try std.testing.expectEqualStrings("mounted /volumes/usb", first.messageSlice());
|
||||
|
||||
const second = parseAt(ring, first.next(ring.tail));
|
||||
try std.testing.expectEqual(@as(u32, 0), second.header.pid);
|
||||
|
||||
+238
-88
@@ -31,6 +31,7 @@ const scheduler = @import("scheduler.zig");
|
||||
const console = @import("console.zig");
|
||||
const sync = @import("sync.zig");
|
||||
const ipc = @import("ipc-synchronous.zig");
|
||||
const user_memory = @import("user-memory.zig");
|
||||
const devices_broker = @import("devices-broker.zig");
|
||||
const irq = @import("irq.zig");
|
||||
const iommu = @import("iommu.zig");
|
||||
@@ -110,20 +111,39 @@ pub const maximum_arguments = 8;
|
||||
/// Ceiling on the `system_spawn` extra-arguments blob (argv[1..], NUL-separated).
|
||||
pub const maximum_argument_bytes = 256;
|
||||
|
||||
/// Longest path `fs_resolve` accepts, longest prefix `fs_mount`/`fs_unmount`
|
||||
/// accept, and longest backend rewrite prefix. Each is also the size of the
|
||||
/// kernel staging buffer the argument is copied into, which is why they are
|
||||
/// named here rather than spelled as literals at the check.
|
||||
pub const maximum_resolve_path = 224;
|
||||
pub const maximum_mount_prefix = 64;
|
||||
pub const maximum_mount_rewrite = 32;
|
||||
|
||||
/// Auxiliary-vector entry types (System V AMD64 process entry). Only what the
|
||||
/// kernel emits today; a C runtime scans the vector until the null terminator.
|
||||
const auxiliary_vector_null: u64 = 0; // AT_NULL — end of the vector
|
||||
const auxiliary_vector_page_size: u64 = 6; // AT_PAGESZ
|
||||
|
||||
// The hand-assembled user program blob (isr.s, .rodata) — the isolation probe.
|
||||
// The hand-assembled user program blobs (isr.s, .rodata) — the isolation probes.
|
||||
const pf_start = @extern([*]const u8, .{ .name = "user_pf_start" });
|
||||
const pf_end = @extern([*]const u8, .{ .name = "user_pf_end" });
|
||||
const sysret_start = @extern([*]const u8, .{ .name = "user_sysret_start" });
|
||||
const sysret_end = @extern([*]const u8, .{ .name = "user_sysret_end" });
|
||||
|
||||
/// The isolation-proof program: reads a kernel-only page, must #PF.
|
||||
pub fn pfBlob() []const u8 {
|
||||
return pf_start[0 .. @intFromPtr(pf_end) - @intFromPtr(pf_start)];
|
||||
}
|
||||
|
||||
/// The SYSRET-guard program: two system calls, the second of which must be copied
|
||||
/// so that it ends at the last executable byte of the user half — its return
|
||||
/// address is then the first non-canonical address. Ends with `syscall`, so the
|
||||
/// caller places it at `page_size - len` inside the page at `user_half_end -
|
||||
/// page_size`; anywhere else and it proves nothing.
|
||||
pub fn nonCanonicalReturnBlob() []const u8 {
|
||||
return sysret_start[0 .. @intFromPtr(sysret_end) - @intFromPtr(sysret_start)];
|
||||
}
|
||||
|
||||
/// What debug_write syscalls produced (accumulated), and the exit system_call's code.
|
||||
pub var write_buffer: [256]u8 = undefined;
|
||||
pub var write_len: usize = 0;
|
||||
@@ -220,8 +240,6 @@ fn system_call(state: *architecture.CpuState) void {
|
||||
.mmap => systemMmap(state),
|
||||
.munmap => systemMunmap(state),
|
||||
.create_ipc_endpoint => systemCreateIpcEndpoint(state),
|
||||
.ipc_register => systemIpcRegister(state),
|
||||
.ipc_lookup => systemIpcLookup(state),
|
||||
.ipc_call => systemIpcCall(state),
|
||||
.ipc_reply_wait => systemIpcReplyWait(state),
|
||||
.ipc_send => systemIpcSend(state),
|
||||
@@ -311,36 +329,6 @@ fn systemCreateIpcEndpoint(state: *architecture.CpuState) void {
|
||||
architecture.setSystemCallResult(state, @intCast(h));
|
||||
}
|
||||
|
||||
/// ipc_register(service_id, handle): publish the caller's endpoint under a
|
||||
/// well-known id so other processes can find it.
|
||||
fn systemIpcRegister(state: *architecture.CpuState) void {
|
||||
// Under the big kernel lock: mutates the global service registry and endpoint
|
||||
// refcounts, which threads of the same (or another) process can race.
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
const id: u32 = @truncate(architecture.systemCallArg(state, 0));
|
||||
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF);
|
||||
architecture.setSystemCallResult(state, @bitCast(ipc.register(id, endpoint)));
|
||||
}
|
||||
|
||||
/// ipc_lookup(service_id) -> handle: find a published endpoint and install a
|
||||
/// handle to it in the caller.
|
||||
fn systemIpcLookup(state: *architecture.CpuState) void {
|
||||
// Under the big kernel lock: reads the global registry, takes an endpoint reference,
|
||||
// and installs a handle — all racy against concurrent threads (this is the path the
|
||||
// display's mouse-listener thread takes to reach the compositor endpoint).
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
const id: u32 = @truncate(architecture.systemCallArg(state, 0));
|
||||
const endpoint = ipc.lookup(id) orelse return failErr(state, ipc.ENOENT);
|
||||
const h = ipc.installHandle(scheduler.current(), endpoint);
|
||||
if (h < 0) {
|
||||
ipc.dropRef(endpoint);
|
||||
return failErr(state, ipc.ENOSPC);
|
||||
}
|
||||
architecture.setSystemCallResult(state, @intCast(h));
|
||||
}
|
||||
|
||||
/// ipc_call(handle, message_ptr, message_len, reply_ptr, reply_cap) -> reply_len.
|
||||
/// Blocks until the server replies; the trap frame lives on this task's kernel
|
||||
/// stack, so it survives the block and receives the result on resume.
|
||||
@@ -355,7 +343,14 @@ fn systemIpcCall(state: *architecture.CpuState) void {
|
||||
/// ipc_reply_wait(handle, reply_ptr, reply_len, receive_ptr, receive_cap) -> receive_len,
|
||||
/// with the sender's badge in the secondary result register (rdx).
|
||||
fn systemIpcReplyWait(state: *architecture.CpuState) void {
|
||||
const endpoint = ipc.resolveHandle(scheduler.current(), architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
|
||||
const t = scheduler.current();
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
|
||||
// Receiving is the owner's privilege, the same rule the notification binders
|
||||
// enforce: a sendable handle means only "you may talk to this". Anything
|
||||
// else and a mount's backend endpoint — which `fs_resolve` installs in every
|
||||
// caller's table — would let a stranger dequeue the requests meant for the
|
||||
// server, taking the capabilities they carry and answering in its name.
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
var badge: u64 = 0;
|
||||
var received_cap: u64 = abi.no_cap;
|
||||
const r = ipc.replyWait(endpoint, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), architecture.systemCallArg(state, 3), architecture.systemCallArg(state, 4), architecture.systemCallArg(state, 5), &badge, &received_cap);
|
||||
@@ -378,6 +373,12 @@ fn systemIpcSend(state: *architecture.CpuState) void {
|
||||
|
||||
/// device_enumerate(buffer, maximum) -> total: snapshot the device table into the caller's
|
||||
/// buffer (up to `maximum` entries), returning the total device count.
|
||||
///
|
||||
/// The broker fills a small kernel chunk which `copyToUser` then places in the
|
||||
/// caller's buffer: the kernel never stores through a user pointer, so a bad one
|
||||
/// is -EFAULT instead of a ring-0 page fault. A DeviceDescriptor is a few hundred
|
||||
/// bytes, so the chunk is deliberately tiny — the 16 KiB kernel stack could not
|
||||
/// hold a whole user-requested array of them.
|
||||
fn systemDeviceEnumerate(state: *architecture.CpuState) void {
|
||||
const buffer_ptr = architecture.systemCallArg(state, 0);
|
||||
const maximum = architecture.systemCallArg(state, 1);
|
||||
@@ -385,8 +386,20 @@ fn systemDeviceEnumerate(state: *architecture.CpuState) void {
|
||||
if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state);
|
||||
const sz = @sizeOf(device_abi.DeviceDescriptor);
|
||||
const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half
|
||||
const out: [*]device_abi.DeviceDescriptor = @ptrFromInt(buffer_ptr);
|
||||
architecture.setSystemCallResult(state, devices_broker.enumerate(out[0..@intCast(cap)]));
|
||||
|
||||
var chunk: [2]device_abi.DeviceDescriptor = undefined;
|
||||
var copied: u64 = 0;
|
||||
var start: usize = 0;
|
||||
while (copied < cap) {
|
||||
const filled = devices_broker.enumerateFrom(start, &chunk);
|
||||
if (filled == 0) break;
|
||||
start += filled;
|
||||
const take = @min(@as(u64, filled), cap - copied);
|
||||
const bytes = std.mem.sliceAsBytes(chunk[0..@intCast(take)]);
|
||||
if (!user_memory.copyToUser(t.address_space, buffer_ptr + copied * sz, bytes)) return failErr(state, ipc.EFAULT);
|
||||
copied += take;
|
||||
}
|
||||
architecture.setSystemCallResult(state, devices_broker.deviceCount());
|
||||
}
|
||||
|
||||
/// device_claim(id) -> 0/-1: take exclusive ownership of a device for this process.
|
||||
@@ -962,14 +975,31 @@ fn systemSpawn(state: *architecture.CpuState) void {
|
||||
if (len == 0 or len > scheduler.maximum_task_name or ptr >= user_half_end or ptr + len > user_half_end) return fail(state);
|
||||
if (arguments_len > maximum_argument_bytes) return fail(state);
|
||||
if (arguments_len != 0 and (arguments_ptr >= user_half_end or arguments_ptr + arguments_len > user_half_end)) return fail(state);
|
||||
// The exit endpoint is a notification binding like signal_bind's and
|
||||
// timer_bind's, so it obeys the same rule: the caller's own mailbox, never a
|
||||
// stranger's. Otherwise any process could have the kernel post child-exit
|
||||
// badges into PID 1 by spawning throwaway children against init's endpoint.
|
||||
const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
|
||||
null
|
||||
else
|
||||
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
|
||||
else block: {
|
||||
const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
break :block endpoint;
|
||||
};
|
||||
const image = ramdisk_image orelse return fail(state);
|
||||
const rd = initial_ramdisk.Reader.init(image) orelse return fail(state);
|
||||
|
||||
const name = @as([*]const u8, @ptrFromInt(ptr))[0..len];
|
||||
// Both buffers come in through the checked copy layer, once. The lengths are
|
||||
// already bounded above, so the staging arrays are small and fixed — and
|
||||
// because the bytes are now the kernel's own, nothing below can be changed
|
||||
// by another thread of the caller between validation and use.
|
||||
var name_storage: [scheduler.maximum_task_name]u8 = undefined;
|
||||
const name = name_storage[0..@intCast(len)];
|
||||
if (!user_memory.copyFromUser(t.address_space, ptr, name)) return failErr(state, ipc.EFAULT);
|
||||
var argument_storage: [maximum_argument_bytes]u8 = undefined;
|
||||
const arguments = argument_storage[0..@intCast(arguments_len)];
|
||||
if (arguments_len != 0 and !user_memory.copyFromUser(t.address_space, arguments_ptr, arguments)) return failErr(state, ipc.EFAULT);
|
||||
|
||||
// Exact path first, basename fallback second; either way argv[0] (and hence
|
||||
// the task name, and the log ring's attribution) is the stored full path.
|
||||
const item = rd.find(name) orelse return fail(state); // no bundled binary by that name
|
||||
@@ -977,8 +1007,7 @@ fn systemSpawn(state: *architecture.CpuState) void {
|
||||
argv[0] = item.name;
|
||||
var argc: usize = 1;
|
||||
if (arguments_len != 0) {
|
||||
const blob = @as([*]const u8, @ptrFromInt(arguments_ptr))[0..arguments_len];
|
||||
var pieces = std.mem.tokenizeScalar(u8, blob, 0);
|
||||
var pieces = std.mem.tokenizeScalar(u8, arguments, 0);
|
||||
while (pieces.next()) |piece| {
|
||||
if (argc == maximum_arguments) return fail(state);
|
||||
argv[argc] = piece;
|
||||
@@ -1004,11 +1033,15 @@ fn systemThreadSpawn(state: *architecture.CpuState) void {
|
||||
if (t.address_space == 0) return fail(state); // kernel tasks own no address space to share
|
||||
if (entry == 0 or entry >= user_half_end) return fail(state);
|
||||
if (stack_top == 0 or stack_top > user_half_end) return fail(state);
|
||||
// The endpoint the thread notifies on exit (how join waits), or none.
|
||||
// The endpoint the thread notifies on exit (how join waits), or none — the
|
||||
// caller's own, like every other notification binding.
|
||||
const exit_endpoint: ?*ipc.Endpoint = if (exit_handle == abi.no_cap)
|
||||
null
|
||||
else
|
||||
ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
|
||||
else block: {
|
||||
const endpoint = ipc.resolveHandle(t, exit_handle) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
break :block endpoint;
|
||||
};
|
||||
const tid = spawnThreadSupervised(t.address_space, entry, stack_top, arg, t.priority, t.id, exit_endpoint, t.leader);
|
||||
if (tid == -ipc.ESRCH) return failErr(state, ipc.ESRCH); // dying group admits no member
|
||||
if (tid < 0) return fail(state);
|
||||
@@ -1129,8 +1162,27 @@ fn systemProcessEnumerate(state: *architecture.CpuState) void {
|
||||
if (t.address_space == 0 or buffer_ptr >= user_half_end) return fail(state);
|
||||
const sz = @sizeOf(abi.ProcessDescriptor);
|
||||
const cap = @min(maximum, (user_half_end - buffer_ptr) / sz); // clamp to the user half
|
||||
const out: [*]abi.ProcessDescriptor = @ptrFromInt(buffer_ptr);
|
||||
architecture.setSystemCallResult(state, scheduler.enumerate(out[0..@intCast(cap)]));
|
||||
|
||||
// The scheduler describes a chunk of the table into kernel memory, then
|
||||
// `copyToUser` places it — the kernel never stores through the user pointer.
|
||||
// Once the caller's buffer is full the walk continues with an empty chunk,
|
||||
// because the result is the true live count, not what fitted.
|
||||
var chunk: [8]abi.ProcessDescriptor = undefined;
|
||||
var copied: u64 = 0;
|
||||
var total: u64 = 0;
|
||||
var cursor: usize = 0;
|
||||
while (true) {
|
||||
const room: []abi.ProcessDescriptor = if (copied < cap) chunk[0..@intCast(@min(chunk.len, cap - copied))] else chunk[0..0];
|
||||
const found = scheduler.enumerateFrom(&cursor, room);
|
||||
total += found.live;
|
||||
if (found.filled != 0) {
|
||||
const bytes = std.mem.sliceAsBytes(chunk[0..found.filled]);
|
||||
if (!user_memory.copyToUser(t.address_space, buffer_ptr + copied * sz, bytes)) return failErr(state, ipc.EFAULT);
|
||||
copied += found.filled;
|
||||
}
|
||||
if (found.done) break;
|
||||
}
|
||||
architecture.setSystemCallResult(state, total);
|
||||
}
|
||||
|
||||
/// process_kill(id) -> 0 / -ESRCH / -EPERM: end the process `id`. Only its
|
||||
@@ -1479,37 +1531,72 @@ pub fn exitReasonOf(caller_id: u32, target_id: u32) i64 {
|
||||
/// subscriptions — that must release what a dead client held and cannot learn it
|
||||
/// any other way (a client that simply never calls again looks like silence).
|
||||
/// Bounded like every kernel table; each entry holds its own endpoint reference.
|
||||
const exit_subscriber_capacity = 8;
|
||||
///
|
||||
/// Sixteen, not eight: a subscription is now what *every* provider with
|
||||
/// per-client state uses to release it — the FAT server's open files, the input,
|
||||
/// power and device-manager subscriber tables (the service harness subscribes for
|
||||
/// them), the compositor's layers, and each USB controller driver's device tokens.
|
||||
/// A single boot already fields six, and a machine with several xHCI controllers
|
||||
/// fields one per controller, so the old ceiling was within two of a service
|
||||
/// silently losing its sweep.
|
||||
const exit_subscriber_capacity = 16;
|
||||
const ExitSubscriber = struct { endpoint: *ipc.Endpoint, owner: u32 };
|
||||
var exit_subscribers: [exit_subscriber_capacity]?ExitSubscriber = .{null} ** exit_subscriber_capacity;
|
||||
|
||||
/// process_subscribe(endpoint): subscribe the caller's endpoint to published exit
|
||||
/// events. Ungated, like process_enumerate — what is running (and dying) is not a
|
||||
/// secret between cooperating processes. -ENOSPC when the table is full.
|
||||
/// process_subscribe(endpoint): subscribe the **caller's own** endpoint to
|
||||
/// published exit events. *Which* deaths one may hear of is ungated, like
|
||||
/// process_enumerate — what is running (and dying) is not a secret between
|
||||
/// cooperating processes. *Whose mailbox* they land in is not: the endpoint must
|
||||
/// be the caller's (`ipc.ownedBy`), or any process could aim the firehose at a
|
||||
/// stranger — filling PID 1's mailbox with exit notices it reads as its own
|
||||
/// children's, and spending the eight-slot table so the services that need
|
||||
/// deaths (the VFS's handle sweep) cannot subscribe at all. -EPERM otherwise,
|
||||
/// -ENOSPC when the table is full.
|
||||
fn systemProcessSubscribe(state: *architecture.CpuState) void {
|
||||
const t = scheduler.current();
|
||||
if (t.address_space == 0) return fail(state);
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
const result = subscribeExits(endpoint, t.id);
|
||||
if (result < 0) return failErr(state, @intCast(-result));
|
||||
architecture.setSystemCallResult(state, 0);
|
||||
}
|
||||
|
||||
/// Take a slot in the published-exit table for `endpoint`, owned by task `owner`.
|
||||
/// The body of `process_subscribe` minus the authorization, so a kernel test can
|
||||
/// exercise the fan-out (several subscribers, one death, every one notified) that
|
||||
/// every provider's release-what-the-dead-client-held sweep is built on. Returns
|
||||
/// 0, or -ENOSPC.
|
||||
pub fn subscribeExits(endpoint: *ipc.Endpoint, owner: u32) i64 {
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
for (&exit_subscribers) |*slot| {
|
||||
if (slot.* == null) {
|
||||
endpoint.refcount += 1; // the slot's own reference, dropped on unsubscribe-by-death
|
||||
slot.* = .{ .endpoint = endpoint, .owner = t.id };
|
||||
return architecture.setSystemCallResult(state, 0);
|
||||
slot.* = .{ .endpoint = endpoint, .owner = owner };
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
failErr(state, ipc.ENOSPC);
|
||||
return -ipc.ENOSPC;
|
||||
}
|
||||
|
||||
/// signal_bind(endpoint): nominate where this process's signals arrive — the
|
||||
/// IRQ-as-IPC pattern a fourth time (docs/process-lifecycle.md). Replacing a
|
||||
/// binding drops the old reference; signals that pended while unbound are
|
||||
/// delivered immediately on bind, coalesced into one notification.
|
||||
///
|
||||
/// The endpoint must be the caller's own (`ipc.ownedBy`), or `signal_bind`
|
||||
/// becomes a signal *forgery* primitive: `process_signal` is deliberately loose
|
||||
/// about the target (a task may always signal itself) because the delivery point
|
||||
/// was assumed to be the target's own mailbox. Aim it elsewhere and a stranger
|
||||
/// signalling itself makes the kernel stamp a genuine `terminate` badge into
|
||||
/// somebody else's queue — which is a shutdown request PID 1 has no way to
|
||||
/// disbelieve.
|
||||
fn systemSignalBind(state: *architecture.CpuState) void {
|
||||
const t = scheduler.current();
|
||||
if (t.address_space == 0) return fail(state);
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
if (t.signal_endpoint) |raw| ipc.dropRef(@ptrCast(@alignCast(raw)));
|
||||
@@ -1578,11 +1665,19 @@ fn timerSweepLocked() void {
|
||||
}
|
||||
}
|
||||
|
||||
/// timer_bind(endpoint, ms): arm a one-shot timer. -ENOSPC when the table is full.
|
||||
/// timer_bind(endpoint, ms): arm a one-shot timer on an endpoint of the caller's
|
||||
/// own (`ipc.ownedBy`; -EPERM otherwise). A timer landing carries no identity —
|
||||
/// that is the whole reason a service may keep exactly one in flight — so a
|
||||
/// timer armed on someone else's endpoint is indistinguishable from one they
|
||||
/// armed themselves, and a loop that re-arms on every landing (init's heartbeat)
|
||||
/// multiplies: N forged timers leave N+1 self-perpetuating beats. The
|
||||
/// sixteen-slot table is a shared resource on top of that. -ENOSPC when it is
|
||||
/// full.
|
||||
fn systemTimerBind(state: *architecture.CpuState) void {
|
||||
const t = scheduler.current();
|
||||
if (t.address_space == 0) return fail(state);
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
const ms = architecture.systemCallArg(state, 1);
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
@@ -1622,12 +1717,17 @@ fn ownedGsi(t: *scheduler.Task, device_id: u64, resource_index: u64) ?u32 {
|
||||
/// irq_bind(device_id, resource_index, endpoint) -> 0/-1: deliver that device's IRQ to the
|
||||
/// endpoint as an asynchronous IPC notification. The driver then blocks in
|
||||
/// IPC_ReplyWait and is woken by the ISR; see system/kernel/irq.zig for the cycle.
|
||||
/// Two gates, both necessary: the device must be *claimed* by the caller
|
||||
/// (`ownedGsi`), and the endpoint must be the caller's own (`ipc.ownedBy`) — a
|
||||
/// claim entitles a driver to its own interrupts, not to post them into a
|
||||
/// stranger's mailbox.
|
||||
fn systemIrqBind(state: *architecture.CpuState) void {
|
||||
const t = scheduler.current();
|
||||
if (t.address_space == 0) return fail(state);
|
||||
const gsi = ownedGsi(t, architecture.systemCallArg(state, 0), architecture.systemCallArg(state, 1)) orelse
|
||||
return fail(state);
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 2)) orelse return fail(state);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM);
|
||||
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
@@ -1648,6 +1748,7 @@ fn systemMsiBind(state: *architecture.CpuState) void {
|
||||
const owner = devices_broker.ownerOf(device_id) orelse return fail(state);
|
||||
if (owner != t.id) return fail(state); // not claimed by this process
|
||||
const endpoint = ipc.resolveHandle(t, architecture.systemCallArg(state, 1)) orelse return failErr(state, ipc.EBADF);
|
||||
if (!ipc.ownedBy(endpoint, t)) return failErr(state, ipc.EPERM); // interrupts land in your own mailbox
|
||||
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
@@ -1682,9 +1783,10 @@ fn systemIrqAck(state: *architecture.CpuState) void {
|
||||
/// The pointer must lie in the user (low) half, so kernel addresses and
|
||||
/// non-canonical values fall outside it and the read below can't be steered at
|
||||
/// kernel data. Length is checked first so the upper-bound add can't overflow.
|
||||
/// Known gap (fine for trusted user code): a pointer into an *unmapped* hole in
|
||||
/// the user half passes the check and the read #PFs -> on_fault halts — a
|
||||
/// self-DoS, not an isolation break. Fault-recovering copy-in is a later item.
|
||||
/// The message then comes in ONCE through the checked copy layer: an unmapped
|
||||
/// hole in the user half is -EFAULT rather than a kernel fault, and the bytes the
|
||||
/// log stamps are the same bytes that were validated (the old code read the user
|
||||
/// buffer twice — once to stage it, once again inside `log.append`).
|
||||
///
|
||||
/// The emit runs under the kernel lock, so a message is atomic on the wire — two
|
||||
/// processes writing from different cores can interleave *messages*, never bytes.
|
||||
@@ -1697,7 +1799,6 @@ fn systemDebugWrite(state: *architecture.CpuState) void {
|
||||
const len = architecture.systemCallArg(state, 1);
|
||||
const level_raw = architecture.systemCallArg(state, 2);
|
||||
if (len <= write_buffer.len and ptr < user_half_end and ptr + len <= user_half_end) {
|
||||
const source: [*]const u8 = @ptrFromInt(ptr);
|
||||
// Levels above the enum range clamp to raw — old two-arg callers land
|
||||
// there naturally (garbage in arg 2 stays harmless).
|
||||
const level: abi.KlogLevel = if (level_raw <= @intFromEnum(abi.KlogLevel.raw))
|
||||
@@ -1707,14 +1808,16 @@ fn systemDebugWrite(state: *architecture.CpuState) void {
|
||||
const t = scheduler.current();
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
@memcpy(write_buffer[0..len], source[0..len]); // keep the latest message
|
||||
// One copy in, under the lock; `write_buffer` (the latest message, which
|
||||
// the kernel tests assert on) doubles as the staging buffer the log reads.
|
||||
if (!user_memory.copyFromUser(t.address_space, ptr, write_buffer[0..len])) return failErr(state, ipc.EFAULT);
|
||||
write_len = len;
|
||||
write_from_user = architecture.fromUser(state);
|
||||
write_count += 1;
|
||||
// The kernel stamps the sender's identity — attribution is structural,
|
||||
// not a prefix convention the payload could forge (and it is stamped
|
||||
// per line inside log.append).
|
||||
log.append(t.id, t.name(), level, source[0..len]);
|
||||
log.append(t.id, t.name(), level, write_buffer[0..len]);
|
||||
architecture.setSystemCallResult(state, len);
|
||||
} else {
|
||||
fail(state);
|
||||
@@ -1729,18 +1832,35 @@ fn systemDebugWrite(state: *architecture.CpuState) void {
|
||||
/// [KlogRecordHeader][name][message] frames out of the byte stream (abi.zig).
|
||||
///
|
||||
/// The mirror of `debug_write`: the same overflow-safe user-half bounds check,
|
||||
/// but the copy runs kernel -> user, under the log lock (inside log.readAt) so
|
||||
/// the stream can't move underneath the copy. A read-only diagnostic.
|
||||
/// but the copy runs kernel -> user. The ring is drained a chunk at a time into a
|
||||
/// kernel staging buffer (each chunk read under the log lock, so the stream can't
|
||||
/// move underneath it) and each chunk is then placed with `copyToUser` — a
|
||||
/// reader may ask for a megabyte, and the kernel stack is 16 KiB. A partial
|
||||
/// result is honest: the reader advances its cursor by what it got. A read-only
|
||||
/// diagnostic.
|
||||
fn systemKlogRead(state: *architecture.CpuState) void {
|
||||
const offset = architecture.systemCallArg(state, 0);
|
||||
const ptr = architecture.systemCallArg(state, 1);
|
||||
const len = architecture.systemCallArg(state, 2);
|
||||
const t = scheduler.current();
|
||||
// Confine the whole destination span to the user (low) half. `len <=
|
||||
// user_half_end - ptr` bounds the length without an overflowing add.
|
||||
if (ptr < user_half_end and len <= user_half_end - ptr) {
|
||||
const dest: [*]u8 = @ptrFromInt(ptr);
|
||||
const n = log.readAt(offset, dest[0..len]) orelse return fail(state);
|
||||
architecture.setSystemCallResult(state, n);
|
||||
var chunk: [512]u8 = undefined;
|
||||
var done: u64 = 0;
|
||||
while (done < len) {
|
||||
const want = @min(@as(u64, chunk.len), len - done);
|
||||
const n = log.readAt(offset + done, chunk[0..@intCast(want)]) orelse {
|
||||
// The cursor fell behind the ring's tail mid-drain. What was
|
||||
// already placed stands; only a first-chunk miss fails the call.
|
||||
if (done == 0) return fail(state);
|
||||
break;
|
||||
};
|
||||
if (n == 0) break; // caught up
|
||||
if (!user_memory.copyToUser(t.address_space, ptr + done, chunk[0..n])) return failErr(state, ipc.EFAULT);
|
||||
done += n;
|
||||
}
|
||||
architecture.setSystemCallResult(state, done);
|
||||
} else {
|
||||
fail(state);
|
||||
}
|
||||
@@ -1753,10 +1873,10 @@ fn systemKlogRead(state: *architecture.CpuState) void {
|
||||
fn systemKlogStatus(state: *architecture.CpuState) void {
|
||||
const ptr = architecture.systemCallArg(state, 0);
|
||||
const size = @sizeOf(abi.KlogStatus);
|
||||
const t = scheduler.current();
|
||||
if (ptr < user_half_end and size <= user_half_end - ptr) {
|
||||
var status = log.status();
|
||||
const dest: [*]u8 = @ptrFromInt(ptr);
|
||||
@memcpy(dest[0..size], std.mem.asBytes(&status)[0..size]);
|
||||
if (!user_memory.copyValueToUser(t.address_space, ptr, &status)) return failErr(state, ipc.EFAULT);
|
||||
architecture.setSystemCallResult(state, 0);
|
||||
} else {
|
||||
fail(state);
|
||||
@@ -1775,10 +1895,12 @@ fn systemFsResolve(state: *architecture.CpuState) void {
|
||||
const flags = architecture.systemCallArg(state, 2);
|
||||
const out_ptr = architecture.systemCallArg(state, 3);
|
||||
const out_cap = architecture.systemCallArg(state, 4);
|
||||
if (path_len == 0 or path_len > 224 or path_ptr >= user_half_end or path_ptr + path_len > user_half_end) return fail(state);
|
||||
if (out_cap != 0 and (out_ptr >= user_half_end or out_ptr + out_cap > user_half_end)) return fail(state);
|
||||
const path = @as([*]const u8, @ptrFromInt(path_ptr))[0..path_len];
|
||||
if (path_len == 0 or path_len > maximum_resolve_path or path_ptr >= user_half_end or path_ptr + path_len > user_half_end) return fail(state);
|
||||
if (out_cap != 0 and !user_memory.userRangeOk(out_ptr, @intCast(out_cap))) return fail(state);
|
||||
const t = scheduler.current();
|
||||
var path_storage: [maximum_resolve_path]u8 = undefined;
|
||||
const path = path_storage[0..@intCast(path_len)];
|
||||
if (!user_memory.copyFromUser(t.address_space, path_ptr, path)) return failErr(state, ipc.EFAULT);
|
||||
|
||||
const flags_lock = sync.enter();
|
||||
defer sync.leave(flags_lock);
|
||||
@@ -1790,14 +1912,15 @@ fn systemFsResolve(state: *architecture.CpuState) void {
|
||||
.backend => |*backend| {
|
||||
// The rewritten path goes back in the out buffer behind a u16
|
||||
// length prefix (a third result register would collide with r8's
|
||||
// argument role in the userspace stub).
|
||||
// argument role in the userspace stub). Both halves are placed with
|
||||
// the checked copy, and *before* the handle is installed, so an
|
||||
// -EFAULT never strands a capability in the caller's table.
|
||||
if (backend.path_len + 2 > out_cap) return fail(state);
|
||||
const prefix = [2]u8{ @intCast(backend.path_len & 0xFF), @intCast(backend.path_len >> 8) };
|
||||
if (!user_memory.copyToUser(t.address_space, out_ptr, &prefix)) return failErr(state, ipc.EFAULT);
|
||||
if (!user_memory.copyToUser(t.address_space, out_ptr + 2, backend.path[0..backend.path_len])) return failErr(state, ipc.EFAULT);
|
||||
const handle = ipc.installHandleDeduped(t, backend.endpoint);
|
||||
if (handle < 0) return fail(state);
|
||||
const destination: [*]u8 = @ptrFromInt(out_ptr);
|
||||
destination[0] = @intCast(backend.path_len & 0xFF);
|
||||
destination[1] = @intCast(backend.path_len >> 8);
|
||||
@memcpy(destination[2..][0..backend.path_len], backend.path[0..backend.path_len]);
|
||||
architecture.setSystemCallResult(state, abi.fs_route_backend);
|
||||
architecture.setSystemCallResult2(state, @intCast(handle));
|
||||
},
|
||||
@@ -1809,6 +1932,12 @@ fn systemFsResolve(state: *architecture.CpuState) void {
|
||||
/// kernel-backed node. read copies file bytes; status copies a FileAttributes;
|
||||
/// readdir copies [DirectoryEntryHeader][name] for the `offset`th child. Reads
|
||||
/// of the immutable initrd never take the kernel lock.
|
||||
///
|
||||
/// Every result reaches the caller through `copyToUser`, never a store through
|
||||
/// the user pointer. `read` stages the file bytes a chunk at a time — a caller
|
||||
/// may ask for the 64 KiB ceiling, which no kernel stack could hold — so a
|
||||
/// mid-way -EFAULT is possible; the call fails and the already-placed prefix is
|
||||
/// meaningless, exactly as a failed read should be.
|
||||
fn systemFsNode(state: *architecture.CpuState) void {
|
||||
const operation = architecture.systemCallArg(state, 0);
|
||||
const node_token = architecture.systemCallArg(state, 1);
|
||||
@@ -1817,16 +1946,24 @@ fn systemFsNode(state: *architecture.CpuState) void {
|
||||
const buf_len = architecture.systemCallArg(state, 4);
|
||||
if (buf_ptr >= user_half_end or buf_len > user_half_end - buf_ptr) return fail(state);
|
||||
const capped = @min(buf_len, 64 * 1024); // bound any single copy
|
||||
const destination: [*]u8 = @ptrFromInt(buf_ptr);
|
||||
const t = scheduler.current();
|
||||
switch (operation) {
|
||||
abi.fs_node_read => {
|
||||
const n = vfs.nodeRead(node_token, offset, destination[0..capped]) orelse return fail(state);
|
||||
architecture.setSystemCallResult(state, n);
|
||||
var chunk: [512]u8 = undefined;
|
||||
var done: u64 = 0;
|
||||
while (done < capped) {
|
||||
const want = @min(@as(u64, chunk.len), capped - done);
|
||||
const n = vfs.nodeRead(node_token, offset + done, chunk[0..@intCast(want)]) orelse return fail(state);
|
||||
if (n == 0) break; // end of file
|
||||
if (!user_memory.copyToUser(t.address_space, buf_ptr + done, chunk[0..n])) return failErr(state, ipc.EFAULT);
|
||||
done += n;
|
||||
}
|
||||
architecture.setSystemCallResult(state, done);
|
||||
},
|
||||
abi.fs_node_status => {
|
||||
var attributes = vfs.nodeStatus(node_token) orelse return fail(state);
|
||||
if (capped < @sizeOf(abi.FileAttributes)) return fail(state);
|
||||
@memcpy(destination[0..@sizeOf(abi.FileAttributes)], std.mem.asBytes(&attributes));
|
||||
if (!user_memory.copyValueToUser(t.address_space, buf_ptr, &attributes)) return failErr(state, ipc.EFAULT);
|
||||
architecture.setSystemCallResult(state, @sizeOf(abi.FileAttributes));
|
||||
},
|
||||
abi.fs_node_readdir => {
|
||||
@@ -1837,10 +1974,13 @@ fn systemFsNode(state: *architecture.CpuState) void {
|
||||
architecture.setSystemCallResult(state, 0); // past the end
|
||||
return;
|
||||
};
|
||||
var header = result.header;
|
||||
// Header and name are staged contiguously so one entry is one copy.
|
||||
var entry: [@sizeOf(abi.DirectoryEntryHeader) + name_buffer.len]u8 = undefined;
|
||||
const header = result.header;
|
||||
const total = header_size + @min(result.name_len, capped - header_size);
|
||||
@memcpy(destination[0..header_size], std.mem.asBytes(&header));
|
||||
@memcpy(destination[header_size..total], name_buffer[0 .. total - header_size]);
|
||||
@memcpy(entry[0..header_size], std.mem.asBytes(&header));
|
||||
@memcpy(entry[header_size..total], name_buffer[0 .. total - header_size]);
|
||||
if (!user_memory.copyToUser(t.address_space, buf_ptr, entry[0..total])) return failErr(state, ipc.EFAULT);
|
||||
architecture.setSystemCallResult(state, total);
|
||||
},
|
||||
else => fail(state),
|
||||
@@ -1857,12 +1997,19 @@ fn systemFsMount(state: *architecture.CpuState) void {
|
||||
const backend_handle = architecture.systemCallArg(state, 2);
|
||||
const rewrite_ptr = architecture.systemCallArg(state, 3);
|
||||
const rewrite_len = architecture.systemCallArg(state, 4);
|
||||
if (prefix_len == 0 or prefix_len > 64 or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state);
|
||||
if (rewrite_len > 32) return fail(state);
|
||||
if (prefix_len == 0 or prefix_len > maximum_mount_prefix or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state);
|
||||
if (rewrite_len > maximum_mount_rewrite) return fail(state);
|
||||
if (rewrite_len != 0 and (rewrite_ptr >= user_half_end or rewrite_ptr + rewrite_len > user_half_end)) return fail(state);
|
||||
const t = scheduler.current();
|
||||
const prefix = @as([*]const u8, @ptrFromInt(prefix_ptr))[0..prefix_len];
|
||||
const rewrite = if (rewrite_len == 0) "" else @as([*]const u8, @ptrFromInt(rewrite_ptr))[0..rewrite_len];
|
||||
// Both strings come in through the checked copy; `mountBackend` copies them
|
||||
// again into the mount table, so these staging buffers only need to outlive
|
||||
// this call.
|
||||
var prefix_storage: [maximum_mount_prefix]u8 = undefined;
|
||||
const prefix = prefix_storage[0..@intCast(prefix_len)];
|
||||
if (!user_memory.copyFromUser(t.address_space, prefix_ptr, prefix)) return failErr(state, ipc.EFAULT);
|
||||
var rewrite_storage: [maximum_mount_rewrite]u8 = undefined;
|
||||
const rewrite = rewrite_storage[0..@intCast(rewrite_len)];
|
||||
if (rewrite_len != 0 and !user_memory.copyFromUser(t.address_space, rewrite_ptr, rewrite)) return failErr(state, ipc.EFAULT);
|
||||
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
@@ -1879,8 +2026,11 @@ fn systemFsMount(state: *architecture.CpuState) void {
|
||||
fn systemFsUnmount(state: *architecture.CpuState) void {
|
||||
const prefix_ptr = architecture.systemCallArg(state, 0);
|
||||
const prefix_len = architecture.systemCallArg(state, 1);
|
||||
if (prefix_len == 0 or prefix_len > 64 or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state);
|
||||
const prefix = @as([*]const u8, @ptrFromInt(prefix_ptr))[0..prefix_len];
|
||||
if (prefix_len == 0 or prefix_len > maximum_mount_prefix or prefix_ptr >= user_half_end or prefix_ptr + prefix_len > user_half_end) return fail(state);
|
||||
const t = scheduler.current();
|
||||
var prefix_storage: [maximum_mount_prefix]u8 = undefined;
|
||||
const prefix = prefix_storage[0..@intCast(prefix_len)];
|
||||
if (!user_memory.copyFromUser(t.address_space, prefix_ptr, prefix)) return failErr(state, ipc.EFAULT);
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
if (!vfs.unmount(prefix)) return fail(state);
|
||||
|
||||
+61
-24
@@ -1203,35 +1203,72 @@ pub fn destroyTaskLocked(t: *Task) void {
|
||||
/// Snapshot the task table into `out` (up to its length), returning the total
|
||||
/// number of live tasks — the kernel half of `process_enumerate`, mirroring
|
||||
/// devices_broker.enumerate. Kernel tasks are included (empty name, supervisor 0):
|
||||
/// an honest `ps` shows the idle tasks too. `out` may be user memory: the caller's
|
||||
/// address space is loaded during its system call, and the same bring-up trust
|
||||
/// applies as for device_enumerate (an unmapped user page faults the kernel).
|
||||
/// an honest `ps` shows the idle tasks too. `out` is always KERNEL memory: the
|
||||
/// system call bounces it out to the caller through the checked copy layer
|
||||
/// (system/kernel/user-memory.zig), so a bad user pointer fails the call instead
|
||||
/// of faulting ring 0.
|
||||
pub fn enumerate(out: []abi.ProcessDescriptor) u64 {
|
||||
var total: u64 = 0;
|
||||
var cursor: usize = 0;
|
||||
while (true) {
|
||||
// Past the buffer, keep walking with an empty chunk: the total is the
|
||||
// whole live count, however few descriptors the caller had room for.
|
||||
const room = if (total < out.len) out[@intCast(total)..] else out[out.len..];
|
||||
const chunk = enumerateFrom(&cursor, room);
|
||||
total += chunk.live;
|
||||
if (chunk.done) return total;
|
||||
}
|
||||
}
|
||||
|
||||
/// What one chunk of the task-table walk found.
|
||||
pub const TaskChunk = struct {
|
||||
/// Live tasks passed in this chunk, whether or not they fit in `out` — this
|
||||
/// is what the running total (and hence `process_enumerate`'s result) counts.
|
||||
live: usize,
|
||||
/// How many of those were written into `out` (`@min(live, out.len)`).
|
||||
filled: usize,
|
||||
/// The cursor reached the end of the table: this was the last chunk.
|
||||
done: bool,
|
||||
};
|
||||
|
||||
/// One chunk of the task table: starting at slot `cursor` (advanced past
|
||||
/// everything scanned), describe up to `out.len` live tasks into `out` — or, with
|
||||
/// an empty `out`, just count the rest. `cursor == tasks.len` ends the walk.
|
||||
///
|
||||
/// The chunked form exists so `process_enumerate` can stage each chunk in a small
|
||||
/// kernel buffer and copy it out with `user_memory.copyToUser`, rather than
|
||||
/// handing a user pointer to the kernel's own stores. A *slot* cursor, rather
|
||||
/// than a "skip the first N live tasks" count, keeps chunks from duplicating or
|
||||
/// losing an entry when a task exits between them.
|
||||
pub fn enumerateFrom(cursor: *usize, out: []abi.ProcessDescriptor) TaskChunk {
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
var total: u64 = 0;
|
||||
for (&tasks) |*t| {
|
||||
var live: usize = 0;
|
||||
var filled: usize = 0;
|
||||
const limit = if (out.len == 0) tasks.len else out.len; // always makes progress
|
||||
while (cursor.* < tasks.len and live < limit) {
|
||||
const t = &tasks[cursor.*];
|
||||
cursor.* += 1;
|
||||
if (t.state == .free or t.state == .reaping) continue; // reaping = already exited
|
||||
if (total < out.len) {
|
||||
const d = &out[total];
|
||||
d.* = .{
|
||||
.id = t.id,
|
||||
.supervisor = t.supervisor,
|
||||
.leader = t.leader,
|
||||
.state = @intFromEnum(@as(abi.ProcessState, switch (t.state) {
|
||||
.ready => .ready,
|
||||
.running => .running,
|
||||
.blocked => .blocked,
|
||||
.free, .reaping => unreachable,
|
||||
})),
|
||||
.priority = t.priority,
|
||||
.name_length = t.name_length,
|
||||
.name = t.name_buffer,
|
||||
};
|
||||
}
|
||||
total += 1;
|
||||
live += 1;
|
||||
if (filled == out.len) continue;
|
||||
out[filled] = .{
|
||||
.id = t.id,
|
||||
.supervisor = t.supervisor,
|
||||
.leader = t.leader,
|
||||
.state = @intFromEnum(@as(abi.ProcessState, switch (t.state) {
|
||||
.ready => .ready,
|
||||
.running => .running,
|
||||
.blocked => .blocked,
|
||||
.free, .reaping => unreachable,
|
||||
})),
|
||||
.priority = t.priority,
|
||||
.name_length = t.name_length,
|
||||
.name = t.name_buffer,
|
||||
};
|
||||
filled += 1;
|
||||
}
|
||||
return total;
|
||||
return .{ .live = live, .filled = filled, .done = cursor.* >= tasks.len };
|
||||
}
|
||||
|
||||
/// Whether the running task is a user process (has its own address space).
|
||||
|
||||
+654
-29
@@ -29,6 +29,7 @@ const process = @import("process.zig");
|
||||
const initial_ramdisk = @import("initial-ramdisk");
|
||||
const kernel_log = @import("log.zig");
|
||||
const kernel_vfs = @import("vfs.zig");
|
||||
const user_memory = @import("user-memory.zig");
|
||||
|
||||
/// Formatted test-marker write. Goes through the kernel log (not straight to
|
||||
/// serial): the log lock is what keeps marker lines from interleaving with
|
||||
@@ -139,8 +140,16 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
||||
faultNoExecute();
|
||||
} else if (eql(case, "fault-null")) {
|
||||
faultNull();
|
||||
} else if (eql(case, "fault-smep")) {
|
||||
faultSmep();
|
||||
} else if (eql(case, "fault-smap")) {
|
||||
faultSmap();
|
||||
} else if (eql(case, "sysret-canonical")) {
|
||||
sysretCanonicalTest();
|
||||
} else if (eql(case, "usermem")) {
|
||||
userMemTest();
|
||||
} else if (eql(case, "user-memory")) {
|
||||
userMemoryTest(boot_information);
|
||||
} else if (eql(case, "user-pf")) {
|
||||
userPfTest();
|
||||
} else if (eql(case, "fault-recovery")) {
|
||||
@@ -243,6 +252,12 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void {
|
||||
containmentTest();
|
||||
} else if (eql(case, "device-manager")) {
|
||||
deviceManagerTest(boot_information);
|
||||
} else if (eql(case, "protocol-registry")) {
|
||||
protocolRegistryTest(boot_information);
|
||||
} else if (eql(case, "protocol-denied")) {
|
||||
protocolDeniedTest(boot_information);
|
||||
} else if (eql(case, "protocol-conformance")) {
|
||||
protocolConformanceTest(boot_information);
|
||||
} else if (eql(case, "reboot")) {
|
||||
rebootTest();
|
||||
} else {
|
||||
@@ -756,6 +771,15 @@ fn sleepTest() void {
|
||||
// --- SMP parallelism ------------------------------------------------------
|
||||
|
||||
var seen_core = [_]bool{false} ** 8;
|
||||
/// Whether the core this worker ran on had SMEP on in its own CR4. Recorded per
|
||||
/// core because CR4 is per-core state: the boot processor enabling it says
|
||||
/// nothing about the ones the trampoline brought up, and the whole hardening is
|
||||
/// only as wide as its narrowest core.
|
||||
var seen_core_smep = [_]bool{false} ** 8;
|
||||
/// The same, for SMAP. A core that came up without it would still be able to read
|
||||
/// and write user pages from ring 0 while every other core could not — a hole
|
||||
/// whose only symptom is that the tripwire never trips there.
|
||||
var seen_core_smap = [_]bool{false} ** 8;
|
||||
var smp_running: bool = true;
|
||||
|
||||
/// A worker that, while running, records which core it's executing on. Spread across
|
||||
@@ -764,7 +788,11 @@ fn smpWorker() void {
|
||||
const p: *volatile bool = &smp_running;
|
||||
while (p.*) {
|
||||
const c = scheduler.currentCpuIndex();
|
||||
if (c < seen_core.len) seen_core[c] = true;
|
||||
if (c < seen_core.len) {
|
||||
seen_core[c] = true;
|
||||
seen_core_smep[c] = architecture.supervisorExecutePreventionEnabled();
|
||||
seen_core_smap[c] = architecture.supervisorAccessPreventionEnabled();
|
||||
}
|
||||
}
|
||||
scheduler.exit();
|
||||
}
|
||||
@@ -794,6 +822,36 @@ fn smpTest() void {
|
||||
log("DANOS-SMP: workers ran on {d} distinct core(s)\n", .{cores_seen});
|
||||
check("tasks ran on multiple cores in parallel", cores_seen >= 2);
|
||||
|
||||
// Every core that ran work must have had SMEP on, not just the one that
|
||||
// booted: an application processor climbs through the trampoline with CR4
|
||||
// bare and sets the bit itself on the way in, so a hardening that reached
|
||||
// only the boot processor would leave every other core able to execute a
|
||||
// user page in ring 0 — and would look identical from the boot log. Only
|
||||
// asserted where the platform has SMEP at all, so a machine without it
|
||||
// still passes rather than failing for the one reason that is not a bug.
|
||||
if (architecture.supervisorExecutePreventionEnabled()) {
|
||||
var hardened: u32 = 0;
|
||||
for (seen_core, seen_core_smep) |ran, smep| {
|
||||
if (ran and smep) hardened += 1;
|
||||
}
|
||||
log("DANOS-SMP: {d} of {d} core(s) had supervisor execute prevention\n", .{ hardened, cores_seen });
|
||||
check("every core that ran work had SMEP enabled", hardened == cores_seen);
|
||||
}
|
||||
|
||||
// And the same for SMAP, for the same reason and with one more of its own: the
|
||||
// bit is refused until the boot processor has patched the `clac` into the shared
|
||||
// interrupt entry, so a core reporting SMAP on is also a core confirming it came
|
||||
// up after that patch — the ordering the whole scheme rests on, checked from the
|
||||
// far end. Gated on the running core the same way, so a CPU without SMAP passes.
|
||||
if (architecture.supervisorAccessPreventionEnabled()) {
|
||||
var hardened: u32 = 0;
|
||||
for (seen_core, seen_core_smap) |ran, smap| {
|
||||
if (ran and smap) hardened += 1;
|
||||
}
|
||||
log("DANOS-SMP: {d} of {d} core(s) had supervisor access prevention\n", .{ hardened, cores_seen });
|
||||
check("every core that ran work had SMAP enabled", hardened == cores_seen);
|
||||
}
|
||||
|
||||
// Bring-up is done, so the trampoline frame must be inert: zeroed (no stale code)
|
||||
// and non-executable (W^X restored). It's armed only while a core is climbing.
|
||||
const tramp = architecture.trampolinePage();
|
||||
@@ -1023,6 +1081,103 @@ fn userMemTest() void {
|
||||
result();
|
||||
}
|
||||
|
||||
/// The checked copy layer (system/kernel/user-memory.zig), against a scratch
|
||||
/// address space built here rather than a live process — so the refusals can be
|
||||
/// provoked exactly: a kernel-half address, an unmapped user page, and a user
|
||||
/// page mapped read-only. Then the fixture proves the same refusals reach ring 3
|
||||
/// as -errno instead of a kernel fault.
|
||||
fn userMemoryTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: user-memory\n", .{});
|
||||
const base_free = pmm.stats().free_frames;
|
||||
|
||||
const address_space = architecture.createAddressSpace() orelse {
|
||||
check("created a scratch address space", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
check("created a scratch address space", address_space != 0);
|
||||
|
||||
// Three consecutive pages: two writable, the third read-only — so a copy that
|
||||
// straddles into the third proves the write check applies per page, not just
|
||||
// to the first one the walk touches.
|
||||
const writable_pages = 2;
|
||||
const total_pages = 3;
|
||||
const arena = process.heap_arena_base;
|
||||
var frames: [total_pages]u64 = undefined;
|
||||
var mapped: usize = 0;
|
||||
while (mapped < total_pages) : (mapped += 1) {
|
||||
frames[mapped] = pmm.alloc() orelse break;
|
||||
architecture.mapUserPageInto(address_space, arena + mapped * abi.page_size, frames[mapped], mapped < writable_pages, false);
|
||||
}
|
||||
check("mapped two writable and one read-only user page", mapped == total_pages);
|
||||
if (mapped == total_pages) {
|
||||
const read_only = arena + writable_pages * abi.page_size;
|
||||
const unmapped = arena + total_pages * abi.page_size;
|
||||
const kernel_half: u64 = 0xFFFF_8000_0000_0000;
|
||||
|
||||
var out: [16]u8 = undefined;
|
||||
const pattern = [_]u8{ 0xC0, 0xDE, 0xF0, 0x0D, 0xBA, 0xAD, 0xF0, 0x0D };
|
||||
|
||||
// A round trip through the writable page: what copyToUser placed is what
|
||||
// copyFromUser brings back, and the frame really holds it.
|
||||
const wrote = user_memory.copyToUser(address_space, arena + 32, &pattern);
|
||||
const read_back = user_memory.copyFromUser(address_space, arena + 32, out[0..pattern.len]);
|
||||
const frame_view: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frames[0] + 32));
|
||||
check("copyToUser/copyFromUser round trip", wrote and read_back and
|
||||
eql(out[0..pattern.len], &pattern) and eql(frame_view[0..pattern.len], &pattern));
|
||||
|
||||
// Straddling the 4 KiB boundary between the two writable pages.
|
||||
const straddle = arena + abi.page_size - 4;
|
||||
check("a page-straddling round trip", user_memory.copyToUser(address_space, straddle, &pattern) and
|
||||
user_memory.copyFromUser(address_space, straddle, out[0..pattern.len]) and
|
||||
eql(out[0..pattern.len], &pattern));
|
||||
|
||||
// Kernel-half addresses are refused by the range check, before any walk.
|
||||
check("copyToUser refuses a kernel-half address", !user_memory.copyToUser(address_space, kernel_half, &pattern));
|
||||
check("copyFromUser refuses a kernel-half address", !user_memory.copyFromUser(address_space, kernel_half, out[0..pattern.len]));
|
||||
check("a range running off the end of the user half is refused", !user_memory.copyToUser(address_space, user_memory.user_half_end - 4, &pattern));
|
||||
|
||||
// An unmapped-but-in-range page: the latent kernel fault H1 exists to kill.
|
||||
check("copyToUser refuses an unmapped user page", !user_memory.copyToUser(address_space, unmapped, &pattern));
|
||||
check("copyFromUser refuses an unmapped user page", !user_memory.copyFromUser(address_space, unmapped, out[0..pattern.len]));
|
||||
|
||||
// The leaf permission bits: a read-only user page may be read, never written.
|
||||
check("copyToUser refuses a read-only user mapping", !user_memory.copyToUser(address_space, read_only, &pattern));
|
||||
check("copyFromUser accepts a read-only user mapping", user_memory.copyFromUser(address_space, read_only, out[0..pattern.len]));
|
||||
check("a write straddling into a read-only page is refused", !user_memory.copyToUser(address_space, read_only - 4, &pattern));
|
||||
|
||||
// The kernel's own address space is not a user address space.
|
||||
check("copyToUser refuses address space 0", !user_memory.copyToUser(0, arena, &pattern));
|
||||
check("copyFromUser refuses address space 0", !user_memory.copyFromUser(0, arena, out[0..pattern.len]));
|
||||
}
|
||||
|
||||
var i: usize = 0;
|
||||
while (i < mapped) : (i += 1) {
|
||||
const va = arena + i * abi.page_size;
|
||||
architecture.unmapUserPageInto(address_space, va);
|
||||
pmm.free(frames[i]);
|
||||
}
|
||||
architecture.destroyAddressSpace(address_space);
|
||||
check("no frames leaked (free count restored)", pmm.stats().free_frames == base_free);
|
||||
|
||||
// Now the ring-3 half: the fixture aims bad pointers at the converted system
|
||||
// calls and must get failures back with the machine still running.
|
||||
if (boot_information.initial_ramdisk_len == 0) {
|
||||
check("bootloader handed over an initial_ramdisk", false);
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||
const rd = initial_ramdisk.Reader.init(image) orelse {
|
||||
check("initial_ramdisk image is valid", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
process.setInitialRamdisk(image);
|
||||
check("user-memory-test spawned", spawnNamed(rd, "user-memory-test"));
|
||||
result();
|
||||
}
|
||||
|
||||
// --- synchronous IPC --------------------------------------------------------
|
||||
|
||||
var ipc_endpoint: *ipcsync.Endpoint = undefined;
|
||||
@@ -1535,6 +1690,99 @@ fn faultRecoveryTest(boot_information: *const BootInformation) void {
|
||||
result();
|
||||
}
|
||||
|
||||
/// Spawn a ring-3 process whose second system call has to return to a NON-canonical
|
||||
/// address — the SYSRET hazard, staged the way a hostile program would stage it.
|
||||
/// The blob is copied to the *end* of the last canonical page of the user half, so
|
||||
/// its final `syscall` occupies the last two bytes ring 3 can execute and the return
|
||||
/// RIP the CPU hands the kernel is `user_half_end` itself, the first non-canonical
|
||||
/// address. Hand-built (address space, code page RO+X, stack RW+NX) like
|
||||
/// `spawnFaultingProcess` — a raw blob is not an ELF `spawnProcess` could load.
|
||||
/// Returns the process id, or null if any allocation failed.
|
||||
fn spawnNonCanonicalReturnProcess() ?u32 {
|
||||
const blob = process.nonCanonicalReturnBlob();
|
||||
const code_virtual = process.user_half_end - abi.page_size; // the last canonical page
|
||||
const entry = code_virtual + abi.page_size - blob.len; // ends flush with the page
|
||||
const flags = sync.enter();
|
||||
defer sync.leave(flags);
|
||||
|
||||
const address_space = architecture.createAddressSpace() orelse return null;
|
||||
const code_frame = pmm.alloc() orelse {
|
||||
architecture.destroyAddressSpace(address_space);
|
||||
return null;
|
||||
};
|
||||
// Fill through the physmap (the user mapping is read-only); int3 everywhere the
|
||||
// blob doesn't cover, so a stray entry traps instead of sliding into it.
|
||||
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(code_frame));
|
||||
@memset(code[0..abi.page_size], 0xCC);
|
||||
@memcpy(code[abi.page_size - blob.len .. abi.page_size], blob);
|
||||
architecture.mapUserPageInto(address_space, code_virtual, code_frame, false, true); // RO + X
|
||||
|
||||
const stack_frame = pmm.alloc() orelse {
|
||||
architecture.destroyAddressSpace(address_space); // frees code_frame too — it's mapped
|
||||
return null;
|
||||
};
|
||||
architecture.mapUserPageInto(address_space, process.stack_base_virtual, stack_frame, true, false); // RW + NX
|
||||
|
||||
// Supervised by the calling test task, so exitReasonOf can read the verdict.
|
||||
return scheduler.spawnUserLocked(address_space, entry, process.stack_base_virtual + abi.page_size, 0, 4, "sysret-probe", scheduler.currentId(), null, 0) orelse {
|
||||
architecture.destroyAddressSpace(address_space);
|
||||
return null;
|
||||
};
|
||||
}
|
||||
|
||||
/// The SYSRET canonical-RIP guard (docs/os-development/smep-smap.md, "Adjacent,
|
||||
/// deliberately separate"): a process must not be able to make the kernel fault on
|
||||
/// its own way out of a system call. `sysretq` with a non-canonical RIP in RCX #GPs
|
||||
/// *in ring 0* on Intel — on the kernel stack, with the user's GS base already
|
||||
/// installed — so the exit path checks the return address first and returns through
|
||||
/// `iretq` instead, which faults in ring 3 where a bad address is just a dead
|
||||
/// process.
|
||||
///
|
||||
/// The probe reaches the hazard the way an attacker would: its `syscall` is the last
|
||||
/// two bytes of executable address space, so the return address the CPU saves is the
|
||||
/// first non-canonical one. Three things then have to be true — the guard fired, the
|
||||
/// *process* died of a protection fault (so the fault landed in ring 3, not in the
|
||||
/// kernel), and this task is still here to say so.
|
||||
///
|
||||
/// The counter is what makes this a regression test rather than a decoration.
|
||||
/// Measured with the guard's branch commented out (2026-08-01): QEMU's TCG does not
|
||||
/// model Intel's ring-0 #GP — `sysretq` simply returns to the bad address and the
|
||||
/// process dies in ring 3 anyway, so every *outcome* check still passed and only the
|
||||
/// counter noticed. On real Intel silicon the same run takes the kernel down. Assert
|
||||
/// the mechanism, not just the outcome, whenever the emulator is the softer machine.
|
||||
/// The probe's first system call is deliberately ordinary: it only reaches the second
|
||||
/// one by returning correctly from the first, so the fast path is exercised too.
|
||||
fn sysretCanonicalTest() void {
|
||||
log("DANOS-TEST-BEGIN: sysret-canonical\n", .{});
|
||||
const blob = process.nonCanonicalReturnBlob();
|
||||
check(
|
||||
"the probe's system call number still matches the ABI",
|
||||
blob.len > 1 and blob[0] == 0xB8 and blob[1] == @intFromEnum(abi.SystemCall.current_core),
|
||||
);
|
||||
|
||||
const before = architecture.nonCanonicalReturnCount();
|
||||
check("no return has been refused yet this boot", before == 0);
|
||||
process.fault_kill_count = 0;
|
||||
const probe = spawnNonCanonicalReturnProcess() orelse 0;
|
||||
check("the probe process spawned", probe != 0);
|
||||
|
||||
// Drop below the probe so it gets the core, and wait for the kill.
|
||||
scheduler.setPriority(1);
|
||||
const deadline = architecture.millis() + 5000;
|
||||
while (process.fault_kill_count < 1 and architecture.millis() < deadline) scheduler.yield();
|
||||
scheduler.setPriority(4);
|
||||
|
||||
const refused = architecture.nonCanonicalReturnCount() - before;
|
||||
check("the guard refused exactly one sysretq", refused == 1);
|
||||
check("the probe was killed (not the machine)", process.fault_kill_count == 1);
|
||||
check(
|
||||
"the probe died of a protection fault — the iretq fallback faulted it in ring 3",
|
||||
process.exitReasonOf(scheduler.currentId(), probe) == @intFromEnum(abi.ExitReason.protection_fault),
|
||||
);
|
||||
log("sysret-canonical: {d} non-canonical return(s) refused; core {d} still running\n", .{ refused, scheduler.currentCpuIndex() });
|
||||
result();
|
||||
}
|
||||
|
||||
/// Address-space refcount (docs/threading-plan.md M1): every process holds exactly one
|
||||
/// reference to its address space, released when it dies, so `destroyAddressSpace` runs
|
||||
/// exactly once per space — no leak, no double-free. Spawn and kill several ring-3
|
||||
@@ -1970,7 +2218,7 @@ fn initTest(boot_information: *const BootInformation) void {
|
||||
check("init loaded and spawned as a process", spawned);
|
||||
|
||||
// Wait (real time) until the LAST write is a heartbeat — proving init got
|
||||
// through its boot chatter (heap ok, the /etc/init.csv lookup) and settled
|
||||
// through its boot chatter (heap ok, the /system/configuration/init.csv lookup) and settled
|
||||
// into its beat-and-sleep loop (~1 s between beats). Waiting on the text
|
||||
// rather than a raw write count: the boot chatter alone satisfies a count,
|
||||
// which is exactly the too-early check that used to fail here.
|
||||
@@ -2109,7 +2357,7 @@ fn processKillTest(boot_information: *const BootInformation) void {
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
|
||||
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "spinner" }, me, endpoint) catch 0;
|
||||
spinner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "spinner" }, me, endpoint) catch 0;
|
||||
break;
|
||||
}
|
||||
check("process-test spawned as the supervised spinner victim", spinner != 0);
|
||||
@@ -2142,9 +2390,67 @@ fn processKillTest(boot_information: *const BootInformation) void {
|
||||
}
|
||||
check("neither victim is listed after its kill", !still_listed);
|
||||
check("a killed id stays dead (-ESRCH on a second kill)", process.killProcess(me, sleeper) == -ipcsync.ESRCH);
|
||||
|
||||
publishedExitChecks(rd, me);
|
||||
result();
|
||||
}
|
||||
|
||||
/// The mechanism every provider's release-what-a-dead-client-held sweep is built
|
||||
/// on (docs/process-lifecycle.md, "Who learns of a death"): a **published** exit,
|
||||
/// fanned out to every subscriber rather than only to the supervisor. The service
|
||||
/// harness's subscriber sweep, the FAT server's open files, the compositor's
|
||||
/// layers and the xHCI driver's device tokens all release on exactly this, and
|
||||
/// several of them are subscribed at once in a normal boot — so what is checked
|
||||
/// here is the fan-out: three independent subscribers, one death, three
|
||||
/// notifications carrying the same badge, none of them the supervisor's.
|
||||
///
|
||||
/// Run at the end of the process-kill case, because a subscription is for every
|
||||
/// death from then on and the checks above spawn victims of their own.
|
||||
fn publishedExitChecks(rd: initial_ramdisk.Reader, me: u32) void {
|
||||
var subscribers: [3]*ipcsync.Endpoint = undefined;
|
||||
var subscribed: usize = 0;
|
||||
while (subscribed < subscribers.len) : (subscribed += 1) {
|
||||
subscribers[subscribed] = ipcsync.createIpcEndpoint() orelse break;
|
||||
if (process.subscribeExits(subscribers[subscribed], me) != 0) break;
|
||||
}
|
||||
check("three endpoints subscribed to published exits", subscribed == subscribers.len);
|
||||
if (subscribed != subscribers.len) return;
|
||||
|
||||
// A supervised child so the supervisor notification remains distinguishable:
|
||||
// it lands on `endpoint`, the published ones on the three above.
|
||||
const supervisor_endpoint = ipcsync.createIpcEndpoint() orelse {
|
||||
check("supervisor endpoint allocated", false);
|
||||
return;
|
||||
};
|
||||
var child: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "args-echo")) continue;
|
||||
child = process.spawnProcessSupervised(item.blob, 4, &.{ "args-echo", "published-exit" }, me, supervisor_endpoint) catch 0;
|
||||
break;
|
||||
}
|
||||
check("a clean-exit child spawned for the published exit", child != 0);
|
||||
if (child == 0) return;
|
||||
|
||||
var badge: u64 = 0;
|
||||
var received_cap: u64 = 0;
|
||||
_ = ipcsync.replyWait(supervisor_endpoint, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
|
||||
check("the supervisor heard the child end", badge == abi.notify_badge_bit | abi.notify_exit_bit | child);
|
||||
|
||||
// The publication happens in the same locked section as the supervisor's
|
||||
// notification and before it, so all three are already queued: a subscriber
|
||||
// that had not heard would block here and time the harness out rather than
|
||||
// pass vacuously.
|
||||
var heard: usize = 0;
|
||||
for (subscribers) |subscriber| {
|
||||
badge = 0;
|
||||
_ = ipcsync.replyWait(subscriber, 0, 0, 0, 0, abi.no_cap, &badge, &received_cap);
|
||||
if (badge == abi.notify_badge_bit | abi.notify_exit_bit | child) heard += 1;
|
||||
}
|
||||
check("every subscriber heard the same death, not just the supervisor", heard == subscribers.len);
|
||||
}
|
||||
|
||||
/// M17.1: a dead process's device claims are released by the reap, so a restarted
|
||||
/// driver can claim its hardware again (docs/process-lifecycle.md iron rule 1).
|
||||
/// First the broker release in isolation — two owners, one released, the other's
|
||||
@@ -2220,7 +2526,7 @@ fn vfsClientDeathTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.write_count = 0;
|
||||
// The full tree: the storage chain must come up for /mnt/usb to exist —
|
||||
// The full tree: the storage chain must come up for /volumes/usb to exist —
|
||||
// the fat server (not a router) now owns client file state and its sweep.
|
||||
process.setInitialRamdisk(image);
|
||||
const init_ok = if (process.spawnBundled("/system/services/init")) true else |_| false;
|
||||
@@ -2295,13 +2601,14 @@ fn signalsTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image); // the parent system_spawns its children by name
|
||||
_ = spawnRegistry(rd); // the service child binds /protocol/test/process
|
||||
process.write_count = 0;
|
||||
var runner: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
|
||||
runner = process.spawnProcessSupervised(item.blob, 4, &.{ "process-test", "signal-run" }, scheduler.currentId(), null) catch 0;
|
||||
runner = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "signal-run" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("signal-run parent spawned", runner != 0);
|
||||
@@ -2343,13 +2650,14 @@ fn driverRestartTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image); // the manager system_spawns drivers by name
|
||||
_ = spawnRegistry(rd); // the drivers bind their contracts
|
||||
process.write_count = 0;
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-restart" }, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-restart" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned in test-restart mode", manager != 0);
|
||||
@@ -2382,12 +2690,13 @@ fn usbReportTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the xhci driver binds /protocol/usb-transfer
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned in test-usb-restart mode", manager != 0);
|
||||
@@ -2414,12 +2723,13 @@ fn deviceListTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the fixture opens /protocol/device-manager
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-usb-restart" }, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-usb-restart" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned in test-usb-restart mode", manager != 0);
|
||||
@@ -2448,13 +2758,14 @@ fn pciCapsTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
|
||||
// Plain mode — no restart drill, whose kill would race the fixture's claim.
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned", manager != 0);
|
||||
@@ -2482,12 +2793,13 @@ fn iommuFaultTest(boot_information: *const BootInformation) void {
|
||||
|
||||
check("IOMMU enabled for the enforcement test", iommu.enabled());
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned", manager != 0);
|
||||
@@ -2523,12 +2835,13 @@ fn pciScanTest(boot_information: *const BootInformation) void {
|
||||
check("the kernel seeded no PCI functions (the walk retired)", brokerPciCount(&buffer) == 0);
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-pci-restart" }, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-pci-restart" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
check("device-manager spawned (test-pci-restart mode)", manager != 0);
|
||||
@@ -2606,7 +2919,7 @@ fn usbStorageTest(boot_information: *const BootInformation) void {
|
||||
|
||||
/// The FAT mount chain: boot the full tree (init spawns the fat server, which
|
||||
/// brings up the USB storage chain, mounts the FAT volume, and mounts itself into
|
||||
/// the VFS at /mnt/usb), then spawn a fat-test client that lists and reads through
|
||||
/// the VFS at /volumes/usb), then spawn a fat-test client that lists and reads through
|
||||
/// the mount. The harness attaches a usb-storage device; the expect regex requires
|
||||
/// the fat mount and the client's success.
|
||||
fn fatMountTest(boot_information: *const BootInformation) void {
|
||||
@@ -2626,6 +2939,10 @@ fn fatMountTest(boot_information: *const BootInformation) void {
|
||||
const init_ok = if (process.spawnBundled("/system/services/init")) true else |_| false;
|
||||
check("init spawned (boots the tree, incl. the fat server)", init_ok);
|
||||
check("fat-test client spawned", spawnNamed(rd, "fat-test"));
|
||||
// The badge-scoping probe rides the same boot: it needs the fat server for a
|
||||
// node id and the compositor for a layer id, and init starts both. It spawns
|
||||
// its own second process — the intruder — with the ids it holds (P4c).
|
||||
check("badge-scope-test owner spawned", spawnNamed(rd, "badge-scope-test"));
|
||||
result();
|
||||
}
|
||||
|
||||
@@ -2676,12 +2993,13 @@ fn acpiReportTest(boot_information: *const BootInformation) void {
|
||||
return;
|
||||
};
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the manager and the acpi service bind theirs
|
||||
var spawned = false;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
_ = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
|
||||
_ = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
|
||||
spawned = true;
|
||||
break;
|
||||
}
|
||||
@@ -2719,7 +3037,7 @@ fn acpiParseTest(boot_information: *const BootInformation) void {
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "discovery")) continue;
|
||||
_ = process.spawnProcessSupervised(item.blob, 4, &.{ "discovery", "1" }, scheduler.currentId(), null) catch 0;
|
||||
_ = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "1" }, scheduler.currentId(), null) catch 0;
|
||||
spawned = true;
|
||||
break;
|
||||
}
|
||||
@@ -2754,7 +3072,7 @@ fn supervisionTest(boot_information: *const BootInformation) void {
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "process-test")) continue;
|
||||
started = if (process.spawnProcess(item.blob, 4, &.{ "process-test", "run" })) true else |_| false;
|
||||
started = if (process.spawnProcess(item.blob, 4, &.{ item.name, "run" })) true else |_| false;
|
||||
break;
|
||||
}
|
||||
check("process-test spawned as the user-space supervisor", started);
|
||||
@@ -2801,11 +3119,13 @@ fn initialRamdiskTest(boot_information: *const BootInformation) void {
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
// The FHS boot tree ferries data files too (/etc/devices.csv,
|
||||
// /etc/init.csv — served read-only by the kernel VFS, never spawned);
|
||||
// only the /system and /test trees hold programs, so only those count
|
||||
// toward the spawn-everything sweep.
|
||||
const is_program = std.mem.startsWith(u8, item.name, "/system/") or
|
||||
// The boot tree ferries data files too (/system/configuration/devices.csv,
|
||||
// /system/configuration/init.csv — served read-only by the kernel VFS,
|
||||
// never spawned); only the /system and /test trees hold programs, and
|
||||
// /system/configuration holds none, so only the rest counts toward the
|
||||
// spawn-everything sweep.
|
||||
const is_program = (std.mem.startsWith(u8, item.name, "/system/") and
|
||||
!std.mem.startsWith(u8, item.name, "/system/configuration/")) or
|
||||
std.mem.startsWith(u8, item.name, "/test/");
|
||||
if (!is_program) continue;
|
||||
programs += 1;
|
||||
@@ -2894,6 +3214,9 @@ fn inputTest(boot_information: *const BootInformation) void {
|
||||
|
||||
process.write_count = 0;
|
||||
process.write_from_user = false;
|
||||
// init (the registry, below) reads its manifests through the kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the input service binds /protocol/input
|
||||
_ = spawnNamed(rd, "input"); // the fan-out service
|
||||
_ = spawnNamed(rd, "input-source"); // a synthetic keyboard publishing events
|
||||
_ = spawnNamed(rd, "input-test"); // the subscriber whose "ok" line is the marker
|
||||
@@ -2935,6 +3258,10 @@ fn displayServiceTest(boot_information: *const BootInformation) void {
|
||||
return;
|
||||
};
|
||||
|
||||
// init (the registry) reads its manifests through the kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the compositor binds /protocol/display
|
||||
|
||||
// Spawn the compositor and hand it the core. Its own serial heartbeats — `display:
|
||||
// online WxH` and `display: presented frame 0` — are what the harness matches (it
|
||||
// reads serial directly, like the fault cases). We don't poll for them in-kernel: a
|
||||
@@ -2971,6 +3298,9 @@ fn displayCursorTest(boot_information: *const BootInformation) void {
|
||||
return;
|
||||
};
|
||||
|
||||
// init (the registry, below) reads its manifests through the kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // input and display bind theirs
|
||||
if (!spawnNamed(rd, "input")) {
|
||||
log("display-cursor: could not spawn the input service\n", .{});
|
||||
result();
|
||||
@@ -3011,6 +3341,9 @@ fn displayDemoTest(boot_information: *const BootInformation) void {
|
||||
return;
|
||||
};
|
||||
|
||||
// init (the registry, below) reads its manifests through the kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the compositor binds /protocol/display
|
||||
if (!spawnNamed(rd, "display")) {
|
||||
log("display-demo: could not spawn the display service\n", .{});
|
||||
result();
|
||||
@@ -3046,6 +3379,9 @@ fn sharedMemoryTest(boot_information: *const BootInformation) void {
|
||||
return;
|
||||
};
|
||||
|
||||
// init (the registry, below) reads its manifests through the kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the server binds /protocol/test/shared-memory
|
||||
if (!spawnNamed(rd, "shared-memory-server")) {
|
||||
log("shared-memory: could not spawn shared-memory-server\n", .{});
|
||||
result();
|
||||
@@ -3083,12 +3419,13 @@ fn virtioGpuTest(boot_information: *const BootInformation) void {
|
||||
// from the kernel device tree, spawns pci-bus, and matches the virtio-gpu class triple to
|
||||
// spawn our driver with the function's device id as argv[1].
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the driver binds /protocol/scanout
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
if (manager == 0) {
|
||||
@@ -3124,12 +3461,13 @@ fn displayNativeTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // display, the manager, and the driver bind theirs
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{"device-manager"}, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{item.name}, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
if (manager == 0) {
|
||||
@@ -3168,12 +3506,13 @@ fn displayReattachTest(boot_information: *const BootInformation) void {
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // display and the restarted driver bind theirs
|
||||
var manager: u32 = 0;
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
const item = rd.entry(i) orelse continue;
|
||||
if (!eql(initial_ramdisk.basename(item.name), "device-manager")) continue;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ "device-manager", "test-scanout-restart" }, scheduler.currentId(), null) catch 0;
|
||||
manager = process.spawnProcessSupervised(item.blob, 4, &.{ item.name, "test-scanout-restart" }, scheduler.currentId(), null) catch 0;
|
||||
break;
|
||||
}
|
||||
if (manager == 0) {
|
||||
@@ -3267,21 +3606,23 @@ fn kernelVfsTest(boot_information: *const BootInformation) void {
|
||||
check("its first bytes are an ELF magic", n == 4 and header[0] == 0x7f and header[1] == 'E' and header[2] == 'L' and header[3] == 'F');
|
||||
}
|
||||
|
||||
// Directories resolve and enumerate: /system lists services/drivers.
|
||||
// Directories resolve and enumerate: /system lists services/drivers/
|
||||
// configuration (the CSV data files ride the same initrd tree).
|
||||
const root_directory = kernel_vfs.resolvePath("/system", false);
|
||||
check("/system resolves to a directory node", root_directory == .kernel_node);
|
||||
var saw_services = false;
|
||||
var saw_drivers = false;
|
||||
var saw_configuration = false;
|
||||
var saw_stray_in_root = false;
|
||||
var saw_files_in_services = false;
|
||||
if (root_directory == .kernel_node) {
|
||||
var cursor: u64 = 0;
|
||||
var name: [64]u8 = undefined;
|
||||
while (kernel_vfs.nodeReaddir(root_directory.kernel_node, cursor, &name)) |entry| : (cursor += 1) {
|
||||
if (eql(name[0..entry.name_len], "services")) saw_services = true else if (eql(name[0..entry.name_len], "drivers")) saw_drivers = true else saw_stray_in_root = true;
|
||||
if (eql(name[0..entry.name_len], "services")) saw_services = true else if (eql(name[0..entry.name_len], "drivers")) saw_drivers = true else if (eql(name[0..entry.name_len], "configuration")) saw_configuration = true else saw_stray_in_root = true;
|
||||
}
|
||||
}
|
||||
check("readdir /system yields services and drivers", saw_services and saw_drivers);
|
||||
check("readdir /system yields services, drivers, configuration", saw_services and saw_drivers and saw_configuration);
|
||||
check("readdir /system yields nothing else (no /test leakage)", !saw_stray_in_root);
|
||||
const services = kernel_vfs.resolvePath("/system/services", false);
|
||||
if (services == .kernel_node) {
|
||||
@@ -3521,6 +3862,21 @@ fn threadTestMarkerCase(boot_information: *const BootInformation, case_name: []c
|
||||
result();
|
||||
}
|
||||
|
||||
/// Bring up the protocol namespace for a scenario that spawns its providers
|
||||
/// itself. `/protocol` is served by init, PID 1 — but a scenario case wants the
|
||||
/// naming layer without init's whole service list underneath it, so init is
|
||||
/// started in its `registry` role: it mounts `/protocol`, reads the grants, and
|
||||
/// spawns nothing (docs/os-development/protocol-namespace.md; the plan's
|
||||
/// decision 9). Providers retry their bind, so racing the mount is survivable —
|
||||
/// but calling this first makes the race rare.
|
||||
///
|
||||
/// The caller must have published the initial ramdisk already
|
||||
/// (`process.setInitialRamdisk`): init reads its manifests out of it, and every
|
||||
/// `/protocol` resolve goes through the same kernel VFS.
|
||||
fn spawnRegistry(rd: initial_ramdisk.Reader) bool {
|
||||
return spawnNamedWithArg(rd, "init", "registry");
|
||||
}
|
||||
|
||||
fn spawnNamed(rd: initial_ramdisk.Reader, name: []const u8) bool {
|
||||
var i: u32 = 0;
|
||||
while (i < rd.count) : (i += 1) {
|
||||
@@ -3641,6 +3997,175 @@ fn childDescriptor(hid: []const u8, start: u64, len: u64) device_abi.DeviceDescr
|
||||
/// match `pci-bus`, and spawn it (with the bridge id as its argument) — and the spawned
|
||||
/// pci-bus must reach its own live marker. It uses no special privilege — the same
|
||||
/// `device_enumerate` any process could call.
|
||||
/// P2 — the registrar (docs/os-development/protocol-namespace.md). Bring up
|
||||
/// `/protocol` (init in its registry role) and hand the fixture the core: it
|
||||
/// asserts that an ungranted bind is refused, that the kernel's reserved prefix
|
||||
/// holds, that a name a live provider holds cannot be taken, and that killing a
|
||||
/// provider makes its channel fail while re-resolving the same name reaches the
|
||||
/// restarted instance.
|
||||
///
|
||||
/// It doubles as the security case for PID 1's shared mailbox, since resolving
|
||||
/// `/protocol` hands every process a sendable handle to it: a forged power
|
||||
/// payload, a redirected terminate signal, a timer or exit subscription armed on
|
||||
/// a foreign endpoint, and capability-carrying ping storms against both PID 1 and
|
||||
/// a harness-run service. Those assertions kill the boot when they regress rather
|
||||
/// than printing anything, which is the strongest form available here.
|
||||
///
|
||||
/// The fixture's `protocol-registry: ok` is the marker; each step also prints its
|
||||
/// own line, which the harness's ordered regex reads.
|
||||
fn protocolRegistryTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: protocol-registry\n", .{});
|
||||
if (boot_information.initial_ramdisk_len == 0) {
|
||||
check("bootloader handed over an initial_ramdisk", false);
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||
const rd = initial_ramdisk.Reader.init(image) orelse {
|
||||
check("initial_ramdisk image is valid", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
|
||||
// The fixture spawns its own providers by name, so the ramdisk must be
|
||||
// published; init then mounts /protocol over the same kernel VFS.
|
||||
process.setInitialRamdisk(image);
|
||||
check("registry (init) spawned", spawnRegistry(rd));
|
||||
check("protocol-registry-test spawned", spawnNamedWithArg(rd, "protocol-registry-test", "run"));
|
||||
|
||||
const pass_marker = "protocol-registry: ok";
|
||||
const fail_marker = "protocol-registry: FAIL";
|
||||
scheduler.setPriority(1);
|
||||
const deadline = architecture.millis() + 20000;
|
||||
var saw_pass = false;
|
||||
var saw_fail = false;
|
||||
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
|
||||
if (bufferHas(pass_marker)) saw_pass = true;
|
||||
if (bufferHas(fail_marker)) saw_fail = true;
|
||||
scheduler.yield();
|
||||
}
|
||||
scheduler.setPriority(4);
|
||||
|
||||
check("no step of the registry contract failed", !saw_fail);
|
||||
check("the fixture completed every registry assertion", saw_pass);
|
||||
result();
|
||||
}
|
||||
|
||||
/// P3 — restriction stage one (docs/os-development/protocol-namespace.md). The
|
||||
/// registrar now checks `open` against `/system/configuration/protocol.csv`, and
|
||||
/// a caller with no grant is told exactly what a caller asking for a name nobody
|
||||
/// bound is told.
|
||||
///
|
||||
/// The scenario is the assertion's scaffolding: `/protocol` (init in its registry
|
||||
/// role), the **input service** — which binds a real contract the fixture is
|
||||
/// deliberately not granted — and the fixture. Without a live provider on the
|
||||
/// forbidden name, "refused" and "not bound yet" would be the same observation
|
||||
/// and the case would prove nothing; the fixture reads `/protocol`'s own listing
|
||||
/// to confirm the name is there before it asks for it.
|
||||
///
|
||||
/// The fixture's `protocol-denied: ok` is the marker; each step prints its own
|
||||
/// line, which the harness's ordered regex reads.
|
||||
fn protocolDeniedTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: protocol-denied\n", .{});
|
||||
if (boot_information.initial_ramdisk_len == 0) {
|
||||
check("bootloader handed over an initial_ramdisk", false);
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||
const rd = initial_ramdisk.Reader.init(image) orelse {
|
||||
check("initial_ramdisk image is valid", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
check("registry (init) spawned", spawnRegistry(rd));
|
||||
// The provider of the contract the fixture may NOT reach. It needs no
|
||||
// hardware: it binds /protocol/input and waits for subscribers.
|
||||
check("input service spawned", spawnNamed(rd, "input"));
|
||||
check("protocol-denied-test spawned", spawnNamedWithArg(rd, "protocol-denied-test", "run"));
|
||||
|
||||
const pass_marker = "protocol-denied: ok";
|
||||
const fail_marker = "protocol-denied: FAIL";
|
||||
scheduler.setPriority(1);
|
||||
const deadline = architecture.millis() + 20000;
|
||||
var saw_pass = false;
|
||||
var saw_fail = false;
|
||||
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
|
||||
if (bufferHas(pass_marker)) saw_pass = true;
|
||||
if (bufferHas(fail_marker)) saw_fail = true;
|
||||
scheduler.yield();
|
||||
}
|
||||
scheduler.setPriority(4);
|
||||
|
||||
check("no step of the restriction contract failed", !saw_fail);
|
||||
check("the fixture completed every restriction assertion", saw_pass);
|
||||
result();
|
||||
}
|
||||
|
||||
/// P4a — the reserved verbs, asked of live providers
|
||||
/// (docs/security-track-plan.md P4a; docs/os-development/protocol-namespace.md).
|
||||
/// Every protocol rebased onto `envelope.Define` gets `describe` answered from its
|
||||
/// specification and `-ENOSYS` for a verb it does not define, without its provider
|
||||
/// implementing either — this case is where that stops being a host unit test of
|
||||
/// the generated dispatch and becomes an observation of real providers over real
|
||||
/// IPC.
|
||||
///
|
||||
/// The scenario is the assertion's scaffolding, the same shape `protocol-denied`
|
||||
/// uses: `/protocol` (init in its registry role) plus the providers the fixture is
|
||||
/// granted to reach — the **input service** and the **compositor**, two protocols
|
||||
/// of different sizes and different verb counts, so "uniform" means something. The
|
||||
/// fixture reads `/protocol`'s own listing rather than a list compiled into it, so
|
||||
/// what it checks is what this boot actually bound; the three other P4a protocols
|
||||
/// (vfs, block, scanout) sit behind hardware chains this scenario deliberately does
|
||||
/// not boot, and the fixture names them on serial as unchecked rather than passing
|
||||
/// over them.
|
||||
///
|
||||
/// The fixture's `protocol-conformance: ok` is the marker; each contract it checks
|
||||
/// prints its own line, which the harness's ordered regex reads.
|
||||
fn protocolConformanceTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: protocol-conformance\n", .{});
|
||||
if (boot_information.initial_ramdisk_len == 0) {
|
||||
check("bootloader handed over an initial_ramdisk", false);
|
||||
result();
|
||||
return;
|
||||
}
|
||||
const image = @as([*]const u8, @ptrFromInt(boot_handoff.physicalToVirtual(boot_information.initial_ramdisk_base)))[0..boot_information.initial_ramdisk_len];
|
||||
const rd = initial_ramdisk.Reader.init(image) orelse {
|
||||
check("initial_ramdisk image is valid", false);
|
||||
result();
|
||||
return;
|
||||
};
|
||||
|
||||
process.setInitialRamdisk(image);
|
||||
check("registry (init) spawned", spawnRegistry(rd));
|
||||
// The two providers under test. Neither needs hardware beyond the framebuffer
|
||||
// the kernel already seeded: input binds /protocol/input and waits for
|
||||
// subscribers, and the compositor binds /protocol/display and composes into
|
||||
// that framebuffer (the display-service scenario boots it exactly this way).
|
||||
check("input service spawned", spawnNamed(rd, "input"));
|
||||
check("display service spawned", spawnNamed(rd, "display"));
|
||||
check("protocol-conformance-test spawned", spawnNamedWithArg(rd, "protocol-conformance-test", "run"));
|
||||
|
||||
const pass_marker = "protocol-conformance: ok";
|
||||
const fail_marker = "protocol-conformance: FAIL";
|
||||
scheduler.setPriority(1);
|
||||
const deadline = architecture.millis() + 20000;
|
||||
var saw_pass = false;
|
||||
var saw_fail = false;
|
||||
while (architecture.millis() < deadline and !saw_pass and !saw_fail) {
|
||||
if (bufferHas(pass_marker)) saw_pass = true;
|
||||
if (bufferHas(fail_marker)) saw_fail = true;
|
||||
scheduler.yield();
|
||||
}
|
||||
scheduler.setPriority(4);
|
||||
|
||||
check("no provider failed the reserved-verb contract", !saw_fail);
|
||||
check("the fixture conformance-checked every provider its scenario boots", saw_pass);
|
||||
result();
|
||||
}
|
||||
|
||||
fn deviceManagerTest(boot_information: *const BootInformation) void {
|
||||
log("DANOS-TEST-BEGIN: device-manager\n", .{});
|
||||
if (boot_information.initial_ramdisk_len == 0) {
|
||||
@@ -3660,6 +4185,7 @@ fn deviceManagerTest(boot_information: *const BootInformation) void {
|
||||
// all, it's because the manager discovered the PCI host bridge, matched, and
|
||||
// spawned it.
|
||||
process.setInitialRamdisk(image);
|
||||
_ = spawnRegistry(rd); // the manager binds /protocol/device-manager
|
||||
|
||||
process.write_count = 0;
|
||||
process.write_from_user = false;
|
||||
@@ -3741,9 +4267,9 @@ fn hpetDeviceId() ?u64 {
|
||||
/// 2. After `releaseOwner` for the binding's owner, that same entry is masked again.
|
||||
///
|
||||
/// And one property that can only be checked from kernel state: a *different* owner's
|
||||
/// binding on the same endpoint survives. Endpoints are shared (ipc_register hands out
|
||||
/// references), so teardown keyed on the endpoint pointer rather than the owning task
|
||||
/// would mask a live sibling driver's device line.
|
||||
/// binding on the same endpoint survives. Endpoints are shared (a capability passed in a
|
||||
/// message hands out extra references), so teardown keyed on the endpoint pointer rather
|
||||
/// than the owning task would mask a live sibling driver's device line.
|
||||
fn irqFreeTest() void {
|
||||
log("DANOS-TEST-BEGIN: irqfree\n", .{});
|
||||
|
||||
@@ -3912,6 +4438,105 @@ fn faultNoExecute() void {
|
||||
log("DANOS-TEST-RESULT: FAIL (NX not enforced)\n", .{});
|
||||
}
|
||||
|
||||
/// Verify SMEP: a ring-0 instruction fetch from a *user*-mapped page must fault.
|
||||
///
|
||||
/// The ret2usr shape, staged deliberately — a user code page (present, executable,
|
||||
/// U/S set) mapped into the address space the kernel itself is running on, then
|
||||
/// called from ring 0. CR4.SMEP makes the fetch a #PF; without it the `ret` simply
|
||||
/// returns and the FAIL line below is reached.
|
||||
///
|
||||
/// The CPU reports it as: present (bit 0) + instruction fetch (bit 4) = error code
|
||||
/// 0x11, taken in ring 0, at the user address it tried to fetch from — a #PF on an
|
||||
/// instruction fetch is raised *at* the instruction that could not be fetched, so
|
||||
/// the reported IP is the probe page, not the kernel-half `call` that jumped there.
|
||||
/// The enabled-check up front stops the case passing vacuously on a CPU that has no
|
||||
/// SMEP (where nothing would fault and nothing would be proved).
|
||||
fn faultSmep() void {
|
||||
log("DANOS-TEST-BEGIN: fault-smep\n", .{});
|
||||
if (!architecture.supervisorExecutePreventionEnabled()) {
|
||||
log("DANOS-TEST-RESULT: FAIL (SMEP not enabled in this boot)\n", .{});
|
||||
return;
|
||||
}
|
||||
const frame = pmm.alloc() orelse {
|
||||
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
|
||||
return;
|
||||
};
|
||||
// Fill through the physmap — the user mapping is read-only. A lone `ret` so an
|
||||
// unenforced fetch lands harmlessly back here (and reports FAIL), int3 padding
|
||||
// so a stray slide traps instead of running into whatever the frame held.
|
||||
const code: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
|
||||
@memset(code[0..abi.page_size], 0xCC);
|
||||
code[0] = 0xC3; // ret
|
||||
// RO + X *and user-accessible*, in the kernel's own tables: this task is a kernel
|
||||
// task, so it runs on them (a process would have its own address space).
|
||||
architecture.mapUserPage(process.code_virtual, frame, false, true);
|
||||
|
||||
// Launder the address through empty asm so the backend really forms an indirect
|
||||
// call rather than folding anything about a known constant target.
|
||||
var target: u64 = process.code_virtual;
|
||||
target = asm (""
|
||||
: [ret] "=r" (-> u64),
|
||||
: [in] "0" (target),
|
||||
);
|
||||
const f: *const fn () void = @ptrFromInt(target);
|
||||
f(); // ring-0 instruction fetch from a user page -> #PF
|
||||
log("DANOS-TEST-RESULT: FAIL (SMEP not enforced)\n", .{});
|
||||
}
|
||||
|
||||
/// Verify SMAP: a ring-0 data read from a *user*-mapped page must fault.
|
||||
///
|
||||
/// The bug shape this case stands in for is the ordinary one — a syscall that takes
|
||||
/// the pointer ring 3 handed it and dereferences it. So the probe does exactly that
|
||||
/// and nothing more: an ordinary user data page (present, user-accessible, read-only,
|
||||
/// no-execute) mapped into the address space this kernel task is already running on,
|
||||
/// then read directly rather than through system/kernel/user-memory.zig. With SMAP on
|
||||
/// and EFLAGS.AC clear the load is a #PF; without it the byte comes back and the FAIL
|
||||
/// line below reports the value it should never have seen.
|
||||
///
|
||||
/// The CPU reports it as error code **0x1**: present (bit 0) alone. A SMAP violation
|
||||
/// has no error-code bit of its own — bit 1 stays clear because this is a read, and
|
||||
/// bit 2 (U/S) describes the *access*, which was made in ring 0, not the page. So the
|
||||
/// report is indistinguishable in its bits from any other supervisor read of a present
|
||||
/// page; what identifies it is the pairing with `ring 0 (kernel)` and a user CR2.
|
||||
///
|
||||
/// The seed write goes through the physmap, which is the point in miniature: that is
|
||||
/// the route the checked copy layer takes for every legitimate access to this same
|
||||
/// frame, and SMAP has no objection to it. Only the second access — the one through
|
||||
/// the *user* virtual address — is refused.
|
||||
///
|
||||
/// The enabled-check up front stops the case passing vacuously on a CPU without SMAP,
|
||||
/// and fails rather than skips: a suite that quietly stops testing the tripwire is
|
||||
/// exactly the outcome the tripwire exists to prevent.
|
||||
fn faultSmap() void {
|
||||
log("DANOS-TEST-BEGIN: fault-smap\n", .{});
|
||||
if (!architecture.supervisorAccessPreventionEnabled()) {
|
||||
log("DANOS-TEST-RESULT: FAIL (SMAP not enabled in this boot)\n", .{});
|
||||
return;
|
||||
}
|
||||
const frame = pmm.alloc() orelse {
|
||||
log("DANOS-TEST-RESULT: FAIL (no frame for the probe page)\n", .{});
|
||||
return;
|
||||
};
|
||||
const seed: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(frame));
|
||||
@memset(seed[0..abi.page_size], 0);
|
||||
seed[0] = 0x5A; // a sentinel, so an unenforced read is reported as a value, not a guess
|
||||
// RO + NX *and user-accessible*, in the kernel's own tables: this task is a kernel
|
||||
// task, so it runs on them (a process would have its own address space).
|
||||
architecture.mapUserPage(process.code_virtual, frame, false, false);
|
||||
|
||||
// Launder the address through empty asm for the same reason `fault-null` does:
|
||||
// the backend must form a real load from a runtime address rather than reasoning
|
||||
// about a constant it can see the provenance of.
|
||||
var target: u64 = process.code_virtual;
|
||||
target = asm (""
|
||||
: [ret] "=r" (-> u64),
|
||||
: [in] "0" (target),
|
||||
);
|
||||
const p: *const volatile u8 = @ptrFromInt(target);
|
||||
const value = p.*; // ring-0 data read from a user page -> #PF
|
||||
log("DANOS-TEST-RESULT: FAIL (SMAP not enforced; read 0x{x})\n", .{value});
|
||||
}
|
||||
|
||||
/// Verify the null guard: dereferencing address 0 (page 0 left unmapped) faults.
|
||||
fn faultNull() void {
|
||||
log("DANOS-TEST-BEGIN: fault-null\n", .{});
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
//! The single trusted door between ring 0 and a process's memory.
|
||||
//!
|
||||
//! Kernel code never dereferences a user virtual address. It walks that address
|
||||
//! space's page tables through the physmap — kernel mappings throughout — and
|
||||
//! moves the bytes there. Three properties fall out of that one decision:
|
||||
//!
|
||||
//! - **A bad pointer fails the system call.** danos has no fault-recovering
|
||||
//! copy-in, so a raw dereference of an unmapped-but-in-range user page would
|
||||
//! halt the machine. Here it is a `false` return and an `-EFAULT`.
|
||||
//! - **The copy is a single fetch.** A struct pulled in once cannot be changed
|
||||
//! underneath the checks that follow it — no TOCTOU against a hostile pointer.
|
||||
//! - **It is SMAP-proof by construction.** No ring-0 access to a user-mapped
|
||||
//! page ever happens, so the CR4.SMAP bit needs no `stac` window anywhere
|
||||
//! (docs/os-development/smep-smap.md). There is no `stac` in this tree, and a
|
||||
//! change that adds one is wrong by definition.
|
||||
//!
|
||||
//! The walk enforces the permissions ring 3 itself would face: a read needs the
|
||||
//! leaf user-accessible (U/S at every level), a write needs it writable too
|
||||
//! (R/W at every level). So a syscall argument cannot steer the kernel at a
|
||||
//! kernel-only mapping, nor make it write a process's own read-only text — the
|
||||
//! properties that matter once shared or copy-on-write mappings exist, and the
|
||||
//! reason the "presence only" caveat that used to sit at the top of
|
||||
//! ipc-synchronous.zig is gone.
|
||||
//!
|
||||
//! Scope: this module knows only about *user* address spaces. The kernel side of
|
||||
//! a copy (an IPC reply staged in kernel memory, a bounce buffer) is trusted and
|
||||
//! translated without permission checks — see `resolve`.
|
||||
|
||||
const std = @import("std");
|
||||
const boot_handoff = @import("boot-handoff");
|
||||
const abi = @import("abi");
|
||||
const architecture = @import("architecture");
|
||||
|
||||
const page_size = abi.page_size;
|
||||
|
||||
/// End of the user (low) canonical half. Every user buffer must lie below it, so
|
||||
/// kernel addresses and non-canonical values are refused by the range check
|
||||
/// alone, before any table is read.
|
||||
pub const user_half_end: u64 = 0x0000_8000_0000_0000;
|
||||
|
||||
/// Whether `[virtual, virtual + len)` lies wholly inside the user half. The
|
||||
/// length is compared against the remaining span rather than added to the base,
|
||||
/// so a huge `len` cannot wrap the check.
|
||||
pub fn userRangeOk(virtual: u64, len: usize) bool {
|
||||
if (virtual >= user_half_end) return false;
|
||||
return len <= user_half_end - virtual;
|
||||
}
|
||||
|
||||
/// Resolve one address for a copy. `address_space == 0` means the kernel's own
|
||||
/// tables — trusted, translated as-is. A real address space is a process's, and
|
||||
/// the walk demands what ring 3 would need: user-accessible, plus writable when
|
||||
/// this side of the copy is the destination.
|
||||
///
|
||||
/// A user frame must also be reachable *through the physmap*, because that is how
|
||||
/// the copy loops touch it. The physmap covers RAM only: `paging.init` skips every
|
||||
/// `.mmio` region, while `mmio_map` hands a driver its device's BAR as an ordinary
|
||||
/// user-accessible mapping. Such a page satisfies the permission walk and would
|
||||
/// then fault ring 0 on the physmap alias — the very #PF this layer exists to make
|
||||
/// impossible — so coverage is confirmed before the address is returned, and an
|
||||
/// uncovered frame is refused like any other bad buffer. (Confirming coverage,
|
||||
/// rather than testing the `device_grant` bit, is what keeps physmap-backed RAM
|
||||
/// that merely carries that bit — a scanout surface — usable as a buffer.)
|
||||
pub fn resolve(address_space: u64, virtual: u64, for_write: bool) ?u64 {
|
||||
if (address_space == 0) return architecture.translate(architecture.kernelPageTable(), virtual);
|
||||
const physical = architecture.translateUser(address_space, virtual, for_write) orelse return null;
|
||||
if (architecture.translate(architecture.kernelPageTable(), boot_handoff.physicalToVirtual(physical)) == null) return null;
|
||||
return physical;
|
||||
}
|
||||
|
||||
/// Copy `destination.len` bytes from `user_va` in address space `user_as` into the
|
||||
/// kernel buffer `destination`. False — never a #PF — if the range escapes the
|
||||
/// user half, or any source page is unmapped or not readable from ring 3.
|
||||
/// Handles page-straddling buffers.
|
||||
pub fn copyFromUser(user_as: u64, user_va: u64, destination: []u8) bool {
|
||||
if (user_as == 0) return false; // not a user address space
|
||||
if (!userRangeOk(user_va, destination.len)) return false;
|
||||
var off: usize = 0;
|
||||
while (off < destination.len) {
|
||||
const physical = resolve(user_as, user_va + off, false) orelse return false;
|
||||
const left = page_size - ((user_va + off) & (page_size - 1));
|
||||
const n = @min(left, destination.len - off);
|
||||
const source: [*]const u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
|
||||
@memcpy(destination[off..][0..n], source[0..n]);
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// The write direction: copy the kernel buffer `source` out to `user_va` in
|
||||
/// address space `user_as`. False — never a #PF, never a partial promise — if the
|
||||
/// range escapes the user half, or any destination page is unmapped, kernel-only,
|
||||
/// or read-only for ring 3. (A refusal mid-way may already have written earlier
|
||||
/// pages; the caller fails the whole system call, so the buffer's contents are
|
||||
/// meaningless either way.)
|
||||
///
|
||||
/// The mirror of `copyFromUser`, and the only way kernel data reaches a user
|
||||
/// buffer outside the IPC path's `copyAcross`.
|
||||
pub fn copyToUser(user_as: u64, user_va: u64, source: []const u8) bool {
|
||||
if (user_as == 0) return false; // not a user address space
|
||||
if (!userRangeOk(user_va, source.len)) return false;
|
||||
var off: usize = 0;
|
||||
while (off < source.len) {
|
||||
const physical = resolve(user_as, user_va + off, true) orelse return false;
|
||||
const left = page_size - ((user_va + off) & (page_size - 1));
|
||||
const n = @min(left, source.len - off);
|
||||
const destination: [*]u8 = @ptrFromInt(boot_handoff.physicalToVirtual(physical));
|
||||
@memcpy(destination[0..n], source[off..][0..n]);
|
||||
off += n;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// `copyToUser` for any fixed-layout value — the shape most write-direction
|
||||
/// system calls want (a `KlogStatus`, a `FileAttributes`).
|
||||
pub fn copyValueToUser(user_as: u64, user_va: u64, value: anytype) bool {
|
||||
return copyToUser(user_as, user_va, std.mem.asBytes(value));
|
||||
}
|
||||
+71
-15
@@ -7,7 +7,8 @@
|
||||
//! mount (the initrd trees at /system and /test, the scratch ram nodes) resolves to a
|
||||
//! stateless node TOKEN served directly by `fs_node` (read/status/readdir
|
||||
//! with copy-out). A path under a USERSPACE mount (the fat server at
|
||||
//! /mnt/usb and /var) resolves to the backend's ENDPOINT: the kernel
|
||||
//! /volumes/usb, /system/configuration, and /system/logs) resolves to the
|
||||
//! backend's ENDPOINT: the kernel
|
||||
//! installs a (deduplicated) handle in the caller's table, rewrites the
|
||||
//! path mount-relative, and the caller speaks the unchanged vfs-protocol
|
||||
//! to the backend over the ordinary ipc_call rendezvous. The kernel never
|
||||
@@ -21,9 +22,10 @@
|
||||
//! Mounting is `fs_mount(prefix, backend_handle, rewrite)`: possession of the
|
||||
//! backend endpoint handle is the capability, exactly the trust of the old
|
||||
//! userspace router's op-6 cap-pass. An optional REWRITE prefix maps the mount
|
||||
//! into the backend's namespace ("/var" -> fat's "/var" subtree while the same
|
||||
//! backend also serves "/mnt/usb" from its root), so FHS paths stay decoupled
|
||||
//! from which volume happens to carry them.
|
||||
//! into the backend's namespace ("/system/logs" -> the boot volume's
|
||||
//! identically-named subtree while the same backend also serves "/volumes/usb"
|
||||
//! from its root), so hierarchy paths stay decoupled from which volume happens
|
||||
//! to carry them.
|
||||
|
||||
const std = @import("std");
|
||||
const abi = @import("abi");
|
||||
@@ -99,7 +101,7 @@ var directory_count: usize = 0;
|
||||
/// If `path` lies under `mount_prefix` — equal to it, or the prefix followed by
|
||||
/// a path separator — return the path relative to the mount ("/" for an exact
|
||||
/// match, otherwise the tail beginning with '/'). Null when not under the
|
||||
/// mount, so "/mnt/usb" never captures "/mnt/usbextra".
|
||||
/// mount, so "/volumes/usb" never captures "/volumes/usbextra".
|
||||
pub fn underMount(path: []const u8, mount_prefix: []const u8) ?[]const u8 {
|
||||
if (path.len < mount_prefix.len) return null;
|
||||
if (!std.mem.eql(u8, path[0..mount_prefix.len], mount_prefix)) return null;
|
||||
@@ -166,6 +168,11 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re
|
||||
var slot: ?*Mount = null;
|
||||
for (&mounts) |*m| {
|
||||
if (m.used and std.mem.eql(u8, m.prefixSlice(), prefix)) {
|
||||
// ...except the protocol namespace. Remount-replace is how a
|
||||
// restarted FAT retakes /volumes/usb; letting it retake /protocol
|
||||
// would hand the whole naming layer to whoever asked second.
|
||||
// First mount wins, and init (PID 1) is always first.
|
||||
if (std.mem.eql(u8, prefix, protocol_root)) return;
|
||||
if (m.backend) |old| ipc.dropRef(old);
|
||||
slot = m;
|
||||
break;
|
||||
@@ -182,11 +189,16 @@ fn installMount(prefix: []const u8, kind: MountKind, backend: ?*ipc.Endpoint, re
|
||||
|
||||
// --- resolve -----------------------------------------------------------------
|
||||
|
||||
/// Longest rewritten mount-relative path a backend resolution can carry — the
|
||||
/// size `fs_resolve`'s caller has to have room for, so it is named rather than
|
||||
/// spelled out at the one place that builds it.
|
||||
pub const maximum_backend_path = maximum_rewrite + maximum_prefix + 160;
|
||||
|
||||
pub const Resolved = union(enum) {
|
||||
/// Kernel-served: a permanent node token.
|
||||
kernel_node: u64,
|
||||
/// Backend-served: the endpoint plus the rewritten mount-relative path.
|
||||
backend: struct { endpoint: *ipc.Endpoint, path: [maximum_rewrite + maximum_prefix + 160]u8, path_len: usize },
|
||||
backend: struct { endpoint: *ipc.Endpoint, path: [maximum_backend_path]u8, path_len: usize },
|
||||
not_found: void,
|
||||
};
|
||||
|
||||
@@ -326,20 +338,64 @@ pub fn nodeReaddir(node_token: u64, cursor: u64, name_out: []u8) ?struct { heade
|
||||
|
||||
// --- mount/unmount (syscall bodies; caller resolved the handle) --------------
|
||||
|
||||
/// The writable subtrees a backend may mount beneath an initrd tree — exactly
|
||||
/// these two, nothing else. Longest-prefix resolution then routes them to the
|
||||
/// volume while every other /system and /test path stays initrd-served, so no
|
||||
/// bundled binary can ever be shadowed.
|
||||
const initrd_carve_outs = [_][]const u8{ "/system/configuration", "/system/logs" };
|
||||
|
||||
fn isInitrdCarveOut(prefix: []const u8) bool {
|
||||
for (initrd_carve_outs) |allowed| {
|
||||
if (std.mem.eql(u8, prefix, allowed)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// The protocol namespace's root — a reserved prefix, like the initrd trees.
|
||||
/// Init (PID 1) mounts the registry here once at boot and the prefix then
|
||||
/// refuses everything: a second mount at it, any mount *under* it (which would
|
||||
/// shadow one contract), and its unmount. That is the whole kernel-side residue
|
||||
/// of the naming layer — the registrar authority itself never leaves init
|
||||
/// (docs/os-development/protocol-namespace.md).
|
||||
const protocol_root = "/protocol";
|
||||
|
||||
fn protocolBound() bool {
|
||||
for (&mounts) |*m| {
|
||||
if (m.used and std.mem.eql(u8, m.prefixSlice(), protocol_root)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Whether mounting at `prefix` would touch the protocol namespace. Exactly
|
||||
/// `/protocol` is allowed once — while nothing holds it; anything under it,
|
||||
/// ever, is refused.
|
||||
fn refusesProtocolMount(prefix: []const u8) bool {
|
||||
const relative = underMount(prefix, protocol_root) orelse return false;
|
||||
if (relative.len != 1) return true; // strictly under /protocol: never
|
||||
return protocolBound(); // /protocol itself: first mount wins
|
||||
}
|
||||
|
||||
/// Mount `backend` at `prefix` with an optional backend-side `rewrite` prefix.
|
||||
/// The endpoint reference is taken by the caller (process.zig bumps it); refuses
|
||||
/// shadowing or replacing the initrd trees (/system, /test).
|
||||
/// shadowing or replacing the initrd trees (/system, /test) — except the two
|
||||
/// carve-outs in `initrd_carve_outs`, the writable configuration/log subtrees.
|
||||
pub fn mountBackend(prefix: []const u8, backend: *ipc.Endpoint, rewrite: []const u8) bool {
|
||||
if (!isAbsolute(prefix) or prefix.len < 2 or prefix.len > maximum_prefix) return false;
|
||||
if (rewrite.len > maximum_rewrite) return false;
|
||||
for (&mounts) |*m| { // the initrd trees are not shadowable
|
||||
if (m.used and m.kind == .kernel_initrd and underMount(prefix, m.prefixSlice()) != null) return false;
|
||||
if (refusesProtocolMount(prefix)) return false; // the registry's prefix is claimed once
|
||||
for (&mounts) |*m| { // the initrd trees are not shadowable (carve-outs aside)
|
||||
if (m.used and m.kind == .kernel_initrd and underMount(prefix, m.prefixSlice()) != null) {
|
||||
if (!isInitrdCarveOut(prefix)) return false;
|
||||
}
|
||||
}
|
||||
installMount(prefix, .backend, backend, rewrite);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn unmount(prefix: []const u8) bool {
|
||||
// Unmounting /protocol would delete the naming layer for everyone; nobody
|
||||
// may, init included. The mount lasts the boot.
|
||||
if (std.mem.eql(u8, prefix, protocol_root)) return false;
|
||||
for (&mounts) |*m| {
|
||||
if (m.used and m.kind == .backend and std.mem.eql(u8, m.prefixSlice(), prefix)) {
|
||||
if (m.backend) |endpoint| ipc.dropRef(endpoint);
|
||||
@@ -353,12 +409,12 @@ pub fn unmount(prefix: []const u8) bool {
|
||||
// --- tests (host) ------------------------------------------------------------
|
||||
|
||||
test "underMount matches only at path boundaries" {
|
||||
try std.testing.expectEqualStrings("/", underMount("/mnt/usb", "/mnt/usb").?);
|
||||
try std.testing.expectEqualStrings("/system/kernel", underMount("/mnt/usb/system/kernel", "/mnt/usb").?);
|
||||
try std.testing.expect(underMount("/mnt/usbextra", "/mnt/usb") == null);
|
||||
try std.testing.expect(underMount("/mnt", "/mnt/usb") == null);
|
||||
try std.testing.expect(underMount("/other", "/mnt/usb") == null);
|
||||
try std.testing.expect(underMount("greeting", "/mnt/usb") == null);
|
||||
try std.testing.expectEqualStrings("/", underMount("/volumes/usb", "/volumes/usb").?);
|
||||
try std.testing.expectEqualStrings("/system/kernel", underMount("/volumes/usb/system/kernel", "/volumes/usb").?);
|
||||
try std.testing.expect(underMount("/volumes/usbextra", "/volumes/usb") == null);
|
||||
try std.testing.expect(underMount("/volumes", "/volumes/usb") == null);
|
||||
try std.testing.expect(underMount("/other", "/volumes/usb") == null);
|
||||
try std.testing.expect(underMount("greeting", "/volumes/usb") == null);
|
||||
}
|
||||
|
||||
test "parentOf walks toward the root" {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
const std = @import("std");
|
||||
const device = @import("driver");
|
||||
const channel = @import("channel");
|
||||
const ipc = @import("ipc");
|
||||
const process = @import("process");
|
||||
const service = @import("service");
|
||||
@@ -21,6 +22,7 @@ const logging = @import("logging");
|
||||
const aml = @import("aml");
|
||||
const acpi_ids = @import("acpi-ids");
|
||||
const device_manager_protocol = @import("device-manager-protocol");
|
||||
const envelope = @import("envelope");
|
||||
const power_protocol = @import("power-protocol");
|
||||
/// AML opcode/prefix bytes by name (`zero_opcode`, `byte_prefix`, …) — so the `_HID`
|
||||
/// integer decode names the opcodes instead of bare 0x0A/0x0B/… (docs/coding-standards.md).
|
||||
@@ -58,15 +60,14 @@ const pwrbtn_bit: u16 = 1 << 8;
|
||||
const sci_en_bit: u32 = 1 << 0;
|
||||
const slp_en: u32 = 1 << 13;
|
||||
|
||||
// The `.power` subscribers: endpoints handed over as capabilities, each
|
||||
// receiving events as buffered messages. Dropped on a failed send. The
|
||||
// subscriber's task id is kept too — a shutdown request is honored only from a
|
||||
// subscriber (init subscribes; a stray process does not), the soft gate that
|
||||
// stands in for "only the system supervisor may power off" without hardcoding
|
||||
// a pid the kernel's idle tasks would have taken.
|
||||
const maximum_subscribers = 8;
|
||||
var subscribers: [maximum_subscribers]?ipc.Handle = .{null} ** maximum_subscribers;
|
||||
var subscriber_tasks: [maximum_subscribers]u32 = .{0} ** maximum_subscribers;
|
||||
/// The `.power` subscribers, kept by the service harness (P4c): endpoints handed
|
||||
/// over as capabilities, each receiving events as buffered messages, each swept
|
||||
/// when its task dies. The table remembers which task subscribed, which is what
|
||||
/// the shutdown gate below asks — a shutdown request is honored only from a
|
||||
/// subscriber (init subscribes; a stray process does not), the soft gate that
|
||||
/// stands in for "only the system supervisor may power off" without hardcoding a
|
||||
/// pid the kernel's idle tasks would have taken.
|
||||
const Subscriptions = service.Subscribers(power_protocol.Protocol, void);
|
||||
|
||||
// Pass-1 registration record (see main): what pass 2 reports.
|
||||
const Registered = struct { hid: [8]u8 = .{0} ** 8, hid_len: usize = 0, device_id: u64 = 0, resource_count: u64 = 0 };
|
||||
@@ -189,14 +190,33 @@ pub fn main(init: process.Init) void {
|
||||
readFadt(fadt);
|
||||
s5_valid = readSleepS5(&persistent_namespace);
|
||||
|
||||
// Every name this service needs, resolved before it becomes a provider — see
|
||||
// `manager_channel`. Best-effort, as it has always been: a standalone
|
||||
// bring-up with no device manager still serves power.
|
||||
manager_channel = channel.openEndpoint("device-manager");
|
||||
|
||||
service.run(power_protocol.message_maximum, .{
|
||||
.service = .power,
|
||||
.service = "power",
|
||||
.init = onInit,
|
||||
.on_message = onMessage,
|
||||
.on_notification = onNotification,
|
||||
.subscribers = Subscriptions.hooks,
|
||||
});
|
||||
}
|
||||
|
||||
/// The device manager's channel, opened **before** this service binds its own
|
||||
/// contract — deliberately, and load-bearing.
|
||||
///
|
||||
/// init is the registrar, and init is also this service's one subscriber: the
|
||||
/// moment `power` is bound, init calls us to subscribe. init has a single thread,
|
||||
/// so while it is blocked in that call it cannot answer anyone — including us. If
|
||||
/// we opened a name after binding, the two could cross: init blocked calling us,
|
||||
/// us blocked asking init to resolve a name, neither ever replying. Resolving
|
||||
/// everything we need first makes that impossible, because after the bind this
|
||||
/// service only ever talks to the device manager (which never calls init) and
|
||||
/// then parks in the harness loop, where init's subscribe lands.
|
||||
var manager_channel: ?ipc.Handle = null;
|
||||
|
||||
// Static so the harness callbacks (which run after main's stack frame is gone)
|
||||
// can reach the namespace and interpreter.
|
||||
var persistent_namespace: aml.Namespace = undefined;
|
||||
@@ -209,23 +229,33 @@ fn onInit(endpoint: ipc.Handle) bool {
|
||||
registered_count = 0;
|
||||
walkDevices(persistent_namespace.root, &global_interpreter);
|
||||
|
||||
const manager = ipc.lookup(.device_manager);
|
||||
const manager = manager_channel;
|
||||
var i: usize = 0;
|
||||
while (i < registered_count) : (i += 1) {
|
||||
const entry = registered[i];
|
||||
const hid = entry.hid[0..entry.hid_len];
|
||||
// The devices.csv columns (bus=acpi, hid) then the human-readable name — a
|
||||
// would-be /etc/devices.csv row read straight off the boot log.
|
||||
// would-be /system/configuration/devices.csv row read straight off the boot log.
|
||||
const desc = acpi_ids.description(hid);
|
||||
if (desc.len != 0)
|
||||
std.log.info("device {d} bus=acpi hid={s} — {s} ({d} resources)", .{ entry.device_id, hid, desc, entry.resource_count })
|
||||
else
|
||||
std.log.info("device {d} bus=acpi hid={s} ({d} resources)", .{ entry.device_id, hid, entry.resource_count });
|
||||
if (manager) |h| {
|
||||
var report = device_manager_protocol.ChildAdded{ .bus = @intFromEnum(device_manager_protocol.BusKind.acpi), .parent = node_id, .bus_address = entry.device_id, .identity = 0, .device_id = entry.device_id };
|
||||
// The registered device id is the packet's target, so the body only
|
||||
// says where on the firmware tree the node sits and what it is.
|
||||
var report = device_manager_protocol.ChildAdded{
|
||||
.bus = @intFromEnum(device_manager_protocol.BusKind.acpi),
|
||||
.parent = node_id,
|
||||
.bus_address = entry.device_id,
|
||||
.identity = 0,
|
||||
};
|
||||
@memcpy(report.hid[0..entry.hid_len], entry.hid[0..entry.hid_len]);
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(h, std.mem.asBytes(&report), &reply) catch {};
|
||||
var packet: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
if (device_manager_protocol.Protocol.encodeRequest(.child_added, entry.device_id, report, &.{}, &packet)) |framed| {
|
||||
var reply: [device_manager_protocol.message_maximum]u8 = undefined;
|
||||
_ = ipc.call(h, framed, &reply) catch {};
|
||||
}
|
||||
}
|
||||
}
|
||||
std.log.info("reported {d} device(s) to the manager", .{registered_count});
|
||||
@@ -370,13 +400,21 @@ fn dispatchGpe(n: u32) void {
|
||||
|
||||
fn publishNotify(node: *aml.Node, code: u64) void {
|
||||
// Map the notified device's _HID to a domain event where we recognize it.
|
||||
// The kind IS the packet's verb, so the mapping picks which event to frame
|
||||
// rather than which tag to put in a payload.
|
||||
var hid: [8]u8 = .{0} ** 8;
|
||||
if (readHid(node, &global_interpreter)) |h| hid = h;
|
||||
const which: power_protocol.Event = if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) .battery else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) .ac else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) .lid else .notify;
|
||||
var event = power_protocol.EventMessage{ .event = @intFromEnum(which), .code = @truncate(code) };
|
||||
event.hid = hid;
|
||||
const notice = power_protocol.Notice{ .code = @truncate(code), .hid = hid };
|
||||
std.log.info("power: notify {s} code {d}", .{ hid[0..7], code });
|
||||
publishEvent(std.mem.asBytes(&event));
|
||||
if (std.mem.eql(u8, hid[0..7], "PNP0C0A")) {
|
||||
Subscriptions.publish(.battery, 0, notice);
|
||||
} else if (std.mem.eql(u8, hid[0..7], "ACPI0003")) {
|
||||
Subscriptions.publish(.ac, 0, notice);
|
||||
} else if (std.mem.eql(u8, hid[0..7], "PNP0C0D")) {
|
||||
Subscriptions.publish(.lid, 0, notice);
|
||||
} else {
|
||||
Subscriptions.publish(.notify, 0, notice);
|
||||
}
|
||||
}
|
||||
|
||||
/// Two lowercase hex digits of `n` into `out[0..2]`.
|
||||
@@ -387,23 +425,10 @@ fn writeHex2(out: []u8, n: u32) void {
|
||||
}
|
||||
|
||||
fn publishButton() void {
|
||||
const event = power_protocol.EventMessage{ .event = @intFromEnum(power_protocol.Event.power_button) };
|
||||
publishEvent(std.mem.asBytes(&event));
|
||||
}
|
||||
|
||||
fn publishEvent(bytes: []const u8) void {
|
||||
for (&subscribers) |*slot| {
|
||||
if (slot.*) |handle| {
|
||||
if (!ipc.send(handle, bytes)) slot.* = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn isSubscriber(task: u32) bool {
|
||||
for (&subscribers, 0..) |*slot, si| {
|
||||
if (slot.* != null and subscriber_tasks[si] == task) return true;
|
||||
}
|
||||
return false;
|
||||
// The kind is the packet's operation, so the harness frames it once and pushes
|
||||
// the same bytes to every subscriber. There is no class here: a power event
|
||||
// goes to everyone who asked for power events.
|
||||
Subscriptions.publish(.power_button, 0, .{});
|
||||
}
|
||||
|
||||
/// Enter S5 (soft off): write SLP_TYP|SLP_EN to the PM1 control register(s).
|
||||
@@ -425,46 +450,39 @@ fn enterS5() void {
|
||||
// --- harness callbacks --------------------------------------------------------
|
||||
|
||||
fn onNotification(badge: u64) void {
|
||||
// The only notification the service binds is the SCI (an IRQ badge).
|
||||
_ = badge;
|
||||
// Two kinds of notification reach this loop now. The SCI is the one this
|
||||
// service binds; the published process exits are the harness's, which it has
|
||||
// already used to sweep the subscriber table before calling here. Everything
|
||||
// that is not a bare IRQ badge must therefore be ignored — treating a death
|
||||
// as an interrupt would clear PM1 status the firmware never set.
|
||||
if (badge & (ipc.notify_exit_bit | ipc.notify_timer_bit | ipc.notify_message_bit | ipc.notify_signal_bit) != 0) return;
|
||||
onSci();
|
||||
}
|
||||
|
||||
/// The `.power` protocol: subscribe (endpoint as the call's capability),
|
||||
/// shutdown (PID 1 only). Device discovery uses a different endpoint (the
|
||||
/// device manager's), so nothing here handles ChildAdded.
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, capability: ?ipc.Handle) usize {
|
||||
if (message.len < 1) return 0;
|
||||
switch (message[0]) {
|
||||
@intFromEnum(power_protocol.Operation.subscribe) => {
|
||||
var status: i32 = -1;
|
||||
if (capability) |handle| {
|
||||
for (&subscribers, 0..) |*slot, si| {
|
||||
if (slot.* == null) {
|
||||
slot.* = handle;
|
||||
subscriber_tasks[si] = sender;
|
||||
status = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const r = power_protocol.Reply{ .status = status };
|
||||
@memcpy(reply[0..@sizeOf(power_protocol.Reply)], std.mem.asBytes(&r));
|
||||
return @sizeOf(power_protocol.Reply);
|
||||
},
|
||||
@intFromEnum(power_protocol.Operation.shutdown) => {
|
||||
// Honored only from a power subscriber — init, which has already run
|
||||
// the stop sequence over everything else. The power service is
|
||||
// mechanism (write S5); deciding *when* to shut down and stopping
|
||||
// the rest of the system first is init's policy.
|
||||
const allowed = isSubscriber(sender);
|
||||
const r = power_protocol.Reply{ .status = if (allowed) 0 else -1 };
|
||||
@memcpy(reply[0..@sizeOf(power_protocol.Reply)], std.mem.asBytes(&r));
|
||||
if (allowed) enterS5();
|
||||
return @sizeOf(power_protocol.Reply);
|
||||
},
|
||||
else => return 0,
|
||||
}
|
||||
const Invocation = envelope.Invocation;
|
||||
const Answer = envelope.Answer;
|
||||
|
||||
/// The power contract: the reserved `subscribe` (the subscriber's endpoint as
|
||||
/// the call's capability, answered by the harness) and `shutdown` (subscribers
|
||||
/// only). Device discovery uses a different endpoint — the device manager's — so
|
||||
/// nothing here handles a tree report.
|
||||
fn onMessage(message: []const u8, reply: []u8, sender: u32, arrived: *ipc.Arrival) usize {
|
||||
return Subscriptions.dispatch({}, handlers, message, sender, arrived, reply);
|
||||
}
|
||||
|
||||
/// `subscribe` and `unsubscribe` are absent on purpose: the harness answers both.
|
||||
const handlers = Subscriptions.Handlers{ .shutdown = onShutdown };
|
||||
|
||||
/// Honored only from a power subscriber — init, which has already run the stop
|
||||
/// sequence over everything else. The power service is mechanism (write S5);
|
||||
/// deciding *when* to shut down and stopping the rest of the system first is
|
||||
/// init's policy. The badge is the whole gate: it is kernel-stamped, so nothing
|
||||
/// in the packet can claim to be init. The subscriber table moved into the
|
||||
/// harness; the question it answers has not changed.
|
||||
fn onShutdown(_: void, invocation: Invocation(void), _: Answer(void)) isize {
|
||||
if (!Subscriptions.has(invocation.sender)) return -envelope.EPERM;
|
||||
enterS5();
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Depth-first walk: register + report each present device with a _HID, then
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The acpi service as a binary package (docs/build-packages-plan.md):
|
||||
//! this file names the binary and EXACTLY the modules its source imports —
|
||||
//! the shared recipe and the module-to-domain map live in build-support.
|
||||
//! build-support resolves each name from the domains this zon declares.
|
||||
//!
|
||||
//! The artifact is named "discovery": one swappable process per firmware
|
||||
//! fills the ramdisk's neutral `discovery` slot (docs/discovery.md); the
|
||||
@@ -14,8 +14,8 @@ pub fn build(b: *std.Build) void {
|
||||
.name = "discovery",
|
||||
.root_source_file = b.path("acpi.zig"),
|
||||
.imports = &.{
|
||||
"acpi-ids", "aml", "device-manager-protocol", "driver", "ipc", "logging", "memory",
|
||||
"power-protocol", "process", "service", "time",
|
||||
"acpi-ids", "aml", "channel", "device-manager-protocol", "driver", "envelope",
|
||||
"ipc", "logging", "memory", "power-protocol", "process", "service", "time",
|
||||
},
|
||||
});
|
||||
b.installArtifact(exe);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user