Add input module: broadcast keyboard events over IPC

Programs can now subscribe to keyboard events (key_down/key_up/key_press)
and drivers can broadcast them, through a new user-space input service.

The delivery model is forced by danos IPC: a synchronous rendezvous holds
one pending reply, so a server cannot park N subscribers blocked in a
"wait for next event" call — delivery must be push. But a synchronous push
has no timeout and the kernel never wakes a sender parked on a dead peer's
endpoint, so one dying subscriber would hang all input. So this lands the
roadmap's planned asynchronous buffered send and builds the service on it:

- ipc_send (syscall 26): non-blocking post to an endpoint's bounded payload
  ring, delivered through reply_wait as a buffered message (notify_message_bit).
  A full ring drops the oldest. It can never hang on a dead/slow peer.
- input-protocol + runtime.input helpers (subscribe/next, connectSource/
  publish) — the first real consumer of M13 capability passing: a subscriber
  hands the service its own endpoint as a capability.
- input service (fan-out via ipc_send, dead-subscriber pruning), a synthetic
  input-source, and input-test; the ps2-bus keyboard driver publishes to it.
  Real IRQ1 scancode decoding (which must live in the bus, the PNP0303 owner)
  is a documented follow-up; the source is synthetic for now.
- build/init wiring, an `input` QEMU case, and docs/input.md.

Full QEMU suite 48/48, including the new input case and every IPC/endpoint
regression (ipc, ipc-call, ipc-cap, vfs, hpet, bus, irqfree).
This commit is contained in:
Daniel Samson
2026-07-11 15:03:24 +01:00
parent 2a583d55a8
commit 65244e3103
19 changed files with 765 additions and 5 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ const runtime = @import("runtime");
/// microkernel keeps such choices in user space, not the kernel. Drivers are absent
/// on purpose: the device manager owns those. (A future init reads this from a
/// manifest under /system/services instead of a hardcoded list.)
const boot_services = [_][]const u8{ "vfs", "device-manager" };
const boot_services = [_][]const u8{ "vfs", "input", "device-manager" };
pub fn main() void {
// Prove the heap end to end: allocate through the runtime allocator (which
@@ -0,0 +1,33 @@
//! system/services/input-source — a hardware-free synthetic keyboard source, used to
//! exercise the input service end to end without a real PS/2 controller (the `input` test
//! case, and any bring-up where there is no keyboard). It stands in for a driver: it
//! connects to the input service and `publish`es a rolling stream of key events, which the
//! service broadcasts to every subscriber.
//!
//! It stays silent after startup (no per-event logging) so it can share the boot serial
//! transcript with a subscriber whose output is the test's success marker. The real
//! keyboard driver publishes the same synthetic stream today; swapping in decoded
//! scancodes is a follow-up (see docs/input.md).
const runtime = @import("runtime");
const input = runtime.input;
const system = runtime.system;
pub fn main() void {
var source = input.connectSource() orelse {
_ = system.write("input-source: input service unavailable\n");
return;
};
_ = system.write("input-source: publishing synthetic key events\n");
var step: usize = 0;
while (true) : (step +%= 1) {
_ = source.publish(input.syntheticEvent(step));
system.sleep(200);
}
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}
+39
View File
@@ -0,0 +1,39 @@
//! system/services/input-test — the input service's client and test oracle, the input
//! counterpart of vfs-test. It `subscribe`s to the input service, then loops receiving the
//! events a source broadcasts. Once it has received at least one event it heartbeats
//! `"input-test: ok"` (repeatedly), which the in-kernel `input` test case watches for on
//! the serial log: seeing it proves an event travelled source -> service -> subscriber
//! over IPC and arrived intact.
const std = @import("std");
const runtime = @import("runtime");
const input = runtime.input;
const system = runtime.system;
fn writeLine(comptime fmt: []const u8, arguments: anytype) void {
var line: [128]u8 = undefined;
_ = system.write(std.fmt.bufPrint(&line, fmt, arguments) catch return);
}
pub fn main() void {
var listener = input.subscribe() orelse {
_ = system.write("input-test: could not subscribe\n");
return;
};
_ = system.write("input-test: subscribed\n");
var received: usize = 0;
while (true) {
const event = listener.next() orelse continue;
received += 1;
// Report the round trip. The kernel test matches the "input-test: ok" prefix and
// requires it to recur, so the source staying up keeps this beating.
const kind: input.EventKind = @enumFromInt(event.kind);
writeLine("input-test: ok received {d} last kind={s} code={d} char={d}\n", .{ received, @tagName(kind), event.keycode, event.character });
}
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}
+138
View File
@@ -0,0 +1,138 @@
//! system/services/input — the user-space input service. Shipped in the initial_ramdisk,
//! spawned as a ring-3 process, and published under the well-known `input` service id. It
//! is the fan-out point between **sources** (keyboard drivers) and **subscribers** (any
//! program that wants keyboard events): a source `publish`es a `KeyEvent`, and the service
//! pushes it to every subscriber.
//!
//! The delivery discipline is the whole design (see docs/input.md). The kernel's IPC is a
//! synchronous rendezvous: a server holds one pending reply, so it cannot park N
//! subscribers blocked in a "wait for next event" call. Broadcasting therefore has to be
//! *push* — the service delivering to subscribers. But a synchronous push (`ipc_call`)
//! would let one dead or wedged subscriber hang the whole broadcast, since the kernel
//! never wakes a sender parked on a dead peer's endpoint. So delivery uses the
//! asynchronous `ipc.send`: it posts the event to each subscriber's endpoint queue and
//! returns at once, and can never block on a subscriber. That primitive exists for exactly
//! this ([ipc.md](../../../docs/ipc.md), "asynchronous / buffered send").
//!
//! A subscriber registers by handing the service its own endpoint as a capability (M13
//! capability passing — this service is its first real consumer). The service keeps that
//! handle and `ipc.send`s each event to it.
const std = @import("std");
const runtime = @import("runtime");
const protocol = runtime.input_protocol;
const ipc = runtime.ipc;
const system = runtime.system;
/// One registered subscriber: the endpoint we push events to (a capability it handed us at
/// subscribe time) and the task id that owns it (the subscribe call's badge), so a slot
/// left behind by a subscriber that exited can be reclaimed.
const Subscriber = struct {
used: bool = false,
endpoint: ipc.Handle = 0,
task_id: u32 = 0,
};
var subscribers = [_]Subscriber{.{}} ** 8;
/// Drop any subscriber whose owning process is no longer alive, so its slot (and the
/// endpoint reference it holds) can be reused. Cheap and only run on subscribe — the async
/// `send` to a dead subscriber's orphaned endpoint is harmless (it just fills a queue no
/// one drains), so this is housekeeping, not correctness.
fn pruneDeadSubscribers() void {
var table: [32]system.ProcessDescriptor = undefined;
const total = system.processes(&table);
const count = @min(total, table.len);
for (&subscribers) |*sub| {
if (!sub.used) continue;
var alive = false;
for (table[0..count]) |descriptor| {
if (descriptor.id == sub.task_id) {
alive = true;
break;
}
}
if (!alive) sub.* = .{};
}
}
/// Register `endpoint` (owned by task `task_id`) to receive events. Returns false if the
/// subscriber table is full.
fn addSubscriber(endpoint: ipc.Handle, task_id: u32) bool {
for (&subscribers) |*sub| {
if (!sub.used) {
sub.* = .{ .used = true, .endpoint = endpoint, .task_id = task_id };
return true;
}
}
return false;
}
/// Push `event` to every registered subscriber. `ipc.send` never blocks, so a slow or
/// dead subscriber cannot stall delivery to the others.
fn broadcast(event: protocol.KeyEvent) void {
const bytes = std.mem.asBytes(&event);
for (&subscribers) |*sub| {
if (sub.used) _ = ipc.send(sub.endpoint, bytes);
}
}
/// Handle one request. `got` carries the sender badge (a task id) and, for subscribe, the
/// subscriber's endpoint capability in `got.cap`. Writes a `Reply` into `out` and returns
/// its length.
fn handle(message: []const u8, got: ipc.Received, out: []u8) usize {
const reply = struct {
fn write(buffer: []u8, status: i32) usize {
const header = protocol.Reply{ .status = status };
@memcpy(buffer[0..protocol.reply_size], std.mem.asBytes(&header));
return protocol.reply_size;
}
};
if (message.len < protocol.request_size) return reply.write(out, -1);
const request = std.mem.bytesToValue(protocol.Request, message[0..protocol.request_size]);
switch (@as(protocol.Operation, @enumFromInt(request.operation))) {
.subscribe => {
const endpoint = got.cap orelse return reply.write(out, -1); // no endpoint passed
pruneDeadSubscribers();
if (!addSubscriber(endpoint, @intCast(got.badge))) return reply.write(out, -1); // table full
return reply.write(out, 0);
},
.publish => {
broadcast(request.event);
return reply.write(out, 0);
},
}
}
pub fn main() void {
const endpoint = ipc.createIpcEndpoint() orelse {
_ = system.write("input: no endpoint\n");
return;
};
if (!ipc.register(.input, endpoint)) {
_ = system.write("input: register failed\n");
return;
}
_ = system.write("input: ready\n");
var reply_buffer: [protocol.reply_size]u8 = undefined;
var reply_len: usize = 0;
var receive: [protocol.request_size]u8 = undefined;
while (true) {
const got = ipc.replyWait(endpoint, reply_buffer[0..reply_len], &receive, null);
// Only synchronous client requests (subscribe/publish) arrive here; nothing sends
// this service asynchronous messages, so a notification wake would be spurious.
if (got.isNotification()) {
reply_len = 0;
continue;
}
reply_len = handle(receive[0..got.len], got, &reply_buffer);
}
}
pub const panic = runtime.panic;
comptime {
_ = &runtime.start._start;
}
+87
View File
@@ -0,0 +1,87 @@
//! The input wire protocol — the message format spoken between the user-space input
//! service ([input.zig](input.zig)) and the two kinds of process that reach it: a
//! **source** (a keyboard driver) that `publish`es events, and a **subscriber** (any
//! program) that `subscribe`s and is then pushed each event.
//!
//! Two message shapes ride over one endpoint, tagged by `Operation`, exactly like the
//! [VFS protocol](../vfs/protocol.zig):
//!
//! - **subscribe / publish**: a synchronous `ipc_call` carrying a `Request`. `subscribe`
//! hands the service the subscriber's own endpoint as a capability (`send_cap`);
//! `publish` carries a `KeyEvent`. The reply is a `Reply`.
//! - **delivery**: the service pushes each `KeyEvent` to every subscriber with the
//! asynchronous `ipc_send` — no reply owed, and a dead subscriber can never stall the
//! broadcast (the reason the async primitive exists). The wire form is a bare
//! `KeyEvent`, received in the subscriber's buffer with `Received.isMessage()` set.
//!
//! 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.
/// What happened to a key. `key_down`/`key_up` are the physical make/break; `key_press`
/// is the higher-level "a character was produced" event a source emits alongside a
/// `key_down` for keys that map to a character (carrying it in `KeyEvent.character`).
pub const EventKind = enum(u32) {
key_down = 0, // a key was pressed (make)
key_up = 1, // a key was released (break)
key_press = 2, // a character-producing press; `character` is the Unicode scalar
};
/// One keyboard event, as broadcast to subscribers. Fixed layout (`extern`) because it
/// crosses the IPC boundary by memory copy. A hardware-independent `keycode` names the
/// physical key; `character` is the Unicode scalar for `key_press` (else 0); `modifiers`
/// is a bitmask of the shift/ctrl/alt state (`modifier_*`), 0 until a source tracks it.
pub const KeyEvent = extern struct {
kind: u32, // an EventKind
keycode: u32, // a Keycode — the physical key, layout-independent
character: u32, // Unicode scalar for key_press, else 0
modifiers: u32, // OR of modifier_* bits
};
/// Modifier bits for `KeyEvent.modifiers`.
pub const modifier_shift: u32 = 1 << 0;
pub const modifier_control: u32 = 1 << 1;
pub const modifier_alt: u32 = 1 << 2;
/// A minimal danos-native keycode namespace — enough for the synthetic source and to
/// show the shape. A real set (USB HID usage-style) fills in with the scancode decoder.
pub const Keycode = enum(u32) {
unknown = 0,
a = 4, // deliberately USB-HID-usage-aligned so a real decoder can extend this
b = 5,
c = 6,
d = 7,
e = 8,
enter = 40,
_,
};
/// Which side of a request this is.
pub const Operation = enum(u32) {
subscribe = 0, // register the caller's endpoint (passed as send_cap) to receive events
publish = 1, // a source submits `event` to broadcast to every subscriber
};
/// Request header. For `subscribe`, `event` is ignored and the caller's receive endpoint
/// travels as the call's capability. For `publish`, `event` is the event to broadcast.
pub const Request = extern struct {
operation: u32, // an Operation
_padding: u32 = 0,
event: KeyEvent,
};
/// 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 event_size: usize = @sizeOf(KeyEvent);
comptime {
// The delivery path posts a bare KeyEvent through ipc_send, so it must fit an
// endpoint's async payload slot (abi has no dependency the other way, so the bound
// lives here where the wire form is defined: POST_MAXIMUM is 64).
if (event_size > 64) @compileError("KeyEvent must fit the ipc_send payload (POST_MAXIMUM)");
}