From 65244e3103072986e3070cc986b119ed71ab7fcf Mon Sep 17 00:00:00 2001 From: Daniel Samson <12231216+daniel-samson@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:03:24 +0100 Subject: [PATCH] Add input module: broadcast keyboard events over IPC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- build.zig | 20 +++ docs/README.md | 5 +- docs/input.md | 115 +++++++++++++++ docs/ipc.md | 7 +- docs/syscall.md | 2 + library/runtime/input.zig | 117 +++++++++++++++ library/runtime/ipc.zig | 29 ++++ library/runtime/runtime.zig | 5 + system/abi.zig | 11 ++ system/drivers/ps2-bus/keyboard.zig | 15 +- system/kernel/ipc-synchronous.zig | 80 +++++++++- system/kernel/process.zig | 13 ++ system/kernel/tests.zig | 46 ++++++ system/services/init/init.zig | 2 +- system/services/input-source/input-source.zig | 33 +++++ system/services/input-test/input-test.zig | 39 +++++ system/services/input/input.zig | 138 ++++++++++++++++++ system/services/input/protocol.zig | 87 +++++++++++ test/qemu_test.py | 6 + 19 files changed, 765 insertions(+), 5 deletions(-) create mode 100644 docs/input.md create mode 100644 library/runtime/input.zig create mode 100644 system/services/input-source/input-source.zig create mode 100644 system/services/input-test/input-test.zig create mode 100644 system/services/input/input.zig create mode 100644 system/services/input/protocol.zig diff --git a/build.zig b/build.zig index 823aa09..7ce87b0 100644 --- a/build.zig +++ b/build.zig @@ -178,6 +178,13 @@ pub fn build(b: *std.Build) void { .root_source_file = b.path("system/services/vfs/protocol.zig"), }); + // The input wire protocol: the input service's public interface, exposed as its own + // module the same way vfs-protocol is. Shared by the input service, the runtime's + // `input` helper (subscribe/publish), and every source and subscriber. + const input_protocol_module = b.addModule("input-protocol", .{ + .root_source_file = b.path("system/services/input/protocol.zig"), + }); + // The danos-native user-space runtime: system_call wrappers, the C-convention // heap, IPC helpers, the process start shim, device access. This is the stable // application ABI; POSIX compatibility is a separate library on top (see below). @@ -193,6 +200,7 @@ pub fn build(b: *std.Build) void { .{ .name = "abi", .module = abi_module }, .{ .name = "device-abi", .module = device_abi_module }, .{ .name = "vfs-protocol", .module = vfs_protocol_module }, + .{ .name = "input-protocol", .module = input_protocol_module }, }, }); @@ -302,6 +310,11 @@ pub fn build(b: *std.Build) void { const ps2_keyboard_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "ps2-keyboard", "system/drivers/ps2-bus/keyboard.zig"); const ps2_mouse_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "ps2-mouse", "system/drivers/ps2-bus/mouse.zig"); const device_manager_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "device-manager", "system/services/device-manager/device-manager.zig"); + // 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 = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "input", "system/services/input/input.zig"); + const input_source_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "input-source", "system/services/input-source/input-source.zig"); + const input_test_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "input-test", "system/services/input-test/input-test.zig"); const args_echo_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "args-echo", "system/services/args-echo/args-echo.zig"); const process_test_exe = addUserBinary(b, kernel_target, runtime_module, posix_module, mmio_module, "process-test", "system/services/process-test/process-test.zig"); @@ -327,6 +340,12 @@ pub fn build(b: *std.Build) void { mk_run.addFileArg(ps2_mouse_exe.getEmittedBin()); mk_run.addArg("device-manager"); mk_run.addFileArg(device_manager_exe.getEmittedBin()); + mk_run.addArg("input"); + mk_run.addFileArg(input_exe.getEmittedBin()); + mk_run.addArg("input-source"); + mk_run.addFileArg(input_source_exe.getEmittedBin()); + mk_run.addArg("input-test"); + mk_run.addFileArg(input_test_exe.getEmittedBin()); mk_run.addArg("args-echo"); mk_run.addFileArg(args_echo_exe.getEmittedBin()); mk_run.addArg("process-test"); @@ -337,6 +356,7 @@ pub fn build(b: *std.Build) void { for ([_]struct { *std.Build.Step.Compile, []const u8 }{ .{ vfs_exe, "system/services" }, .{ device_manager_exe, "system/services" }, + .{ input_exe, "system/services" }, .{ hpet_exe, "system/drivers" }, .{ bus_exe, "system/drivers" }, .{ ps2_bus_exe, "system/drivers" }, diff --git a/docs/README.md b/docs/README.md index b02e240..9df0c78 100644 --- a/docs/README.md +++ b/docs/README.md @@ -52,7 +52,10 @@ rather than restate it. Roughly in the order things happen at runtime: microkernel's `ps`/`kill`/SIGCHLD: enumerate as a table snapshot, the supervision link as the kill authority, and child-exit notifications over the same endpoints IRQs arrive on. -16. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and +16. **[input.md](input.md) — the input module.** Broadcasting keyboard events: why a + synchronous rendezvous can't fan out to many listeners, the asynchronous `ipc_send` + primitive built to fix it, and the subscribe/publish service layered on top. +17. **[halting.md](halting.md) — halting.** Why a kernel can't just "exit", and how `while (true) hlt` parks the CPU safely once there's nothing left to do. Start with the north star: diff --git a/docs/input.md b/docs/input.md new file mode 100644 index 0000000..a2110dc --- /dev/null +++ b/docs/input.md @@ -0,0 +1,115 @@ +# The input module: broadcasting keyboard events + +A keyboard driver has one keystroke and *many* programs that might want it — a shell, a +window server, a logger. None of them owns the hardware, and the driver should not know +who is listening. So between the driver and the listeners sits the **input service** +(`system/services/input/`): drivers **publish** events to it, programs **subscribe**, and +it fans each event out to every subscriber. It is an ordinary ring-3 process reached over +IPC, like the [VFS server](../system/services/vfs/vfs.zig) — no kernel knows what a key is. + +Three event kinds cross the wire ([protocol.zig](../system/services/input/protocol.zig)): +`key_down` and `key_up` are the physical make/break; `key_press` is the higher-level +"a character was produced", carrying the Unicode scalar. A `KeyEvent` also has a +layout-independent `keycode` and a `modifiers` bitmask. + +## Why this needed a new kernel primitive + +The interesting part is delivery, and it runs straight into the shape of danos IPC. +[ipc.md](ipc.md) describes a **synchronous rendezvous**: a server holds exactly one +pending reply (`Task.ipc_client`) and *must* answer it on its next `replyWait`. Two +consequences decide the whole design: + +1. **You cannot block N subscribers waiting for "the next event".** A server can hold only + one caller at a time, so the natural "subscriber calls `next_event()` and blocks" API + is impossible for more than one subscriber. Delivery therefore has to be **push** — the + service reaching out to subscribers — not pull. + +2. **A synchronous push can hang the whole service.** If the service delivered with + `ipc_call`, it would block until each subscriber replied. `ipc_call` has no timeout, and + the kernel does **not** wake a caller parked on a *dead* peer's endpoint (it only fails a + peer that was mid-reply — see [process.zig](../system/kernel/process.zig) + `releaseTaskResourcesLocked`). One subscriber that exits mid-delivery would wedge input + for everyone. That is the opposite of the resilience the microkernel is for. + +The fix is the asynchronous send that [ipc.md](ipc.md) had already earmarked as future +work ("asynchronous / buffered send … for notifications between servers"): + +``` +ipc_send(handle, message_ptr, message_len) -> 0 / -errno +``` + +`ipc_send` copies a small payload into the endpoint's **bounded queue** and wakes a +receiver, then returns immediately — it never blocks and so can never hang on a dead or +slow subscriber. The receiver picks it up through the same `replyWait` it already runs: +the wake arrives as a **buffered message** — `notify_badge_bit | notify_message_bit` set in +the badge (distinguishing it from a bare IRQ/child-exit notification), the sender's task id +in the low bits, and the payload in the receive buffer, with no reply owed. The queue holds +16 messages per endpoint; a full queue **drops the oldest**, because a buffered message is +discrete data, not a coalescing "level" like an interrupt. See +[ipc-synchronous.zig](../system/kernel/ipc-synchronous.zig) (`sendLocked`, `popPost`, and +the `replyWait` receive loop). + +This is the async counterpart of `ipc_call`, and the input service is its first consumer. + +## How the pieces fit + +``` + keyboard driver / input-source input service subscriber(s) + -------------------------------- ------------- ------------- + connectSource(); loop: replyWait: subscribe(): + publish(event) ── ipc_call ──▶ publish → broadcast: createIpcEndpoint() + for each sub: callCap(subscribe, + ipc_send(sub_ep) ──────────▶ send_cap = ep) + reply ok loop: next() + subscribe → store sub_ep cap └─ replyWait(ep) + (from the call's capability) → KeyEvent +``` + +- A **subscriber** calls `input.subscribe()` + ([library/runtime/input.zig](../library/runtime/input.zig)): it creates its own endpoint + and hands it to the service as a **capability** (M13 capability passing — the input + service is that feature's first real user). Then it loops on `Subscriber.next()`, which + is a `replyWait` on that endpoint returning each pushed `KeyEvent`. +- A **source** (a keyboard driver) calls `input.connectSource()` and + `Publisher.publish(event)`. 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). On `publish` it `ipc_send`s the event + to every subscriber. On `subscribe` it stores the passed capability 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. + +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 +the service delivers to its endpoint, which only the same thread could receive). + +## Status and follow-ups + +- **Synthetic source, for now.** The `ps2-bus` driver owns PNP0303, which carries *both* + the 0x60/0x64 ports and IRQ1, so reading real scancodes has to live in the bus, not in + [keyboard.zig](../system/drivers/ps2-bus/keyboard.zig). Until that lands, the keyboard + driver (and the hardware-free `input-source` used by the test) publish a synthetic rolling + `A..E` stream via `input.syntheticEvent`. The fan-out path is real; only the bytes are + placeholder. **Follow-up:** the bus binds IRQ1, reads port 0x60, and `ps2-library` + translates scan-set-1 → keycodes; the keyboard driver publishes decoded events. +- **Drop-oldest under overflow** is a defined loss; the 16-slot ring absorbs normal bursts. + Real backpressure/flow-control is future work. +- **`publish` is unauthenticated** — any process may publish, consistent with the current + bring-up trust model (see [driver-model.md](driver-model.md)). A source capability is + future work. + +## Verifying it + +The `input` case (`python3 test/qemu_test.py input`, in +[tests.zig](../system/kernel/tests.zig) `inputTest`) boots the real kernel and spawns the +service, the synthetic source, and a subscriber. It passes only when the subscriber +heartbeats `input-test: ok` — proof that an event travelled source → service → subscriber +over IPC, exercising both `ipc_send` and capability-passing subscription. + +## See also + +- [ipc.md](ipc.md) — the synchronous rendezvous and the notification path `ipc_send` extends. +- [syscall.md](syscall.md) — the system-call surface, including `ipc_send`. +- [driver-model.md](driver-model.md) — class drivers, capability passing (M13), the trust model. diff --git a/docs/ipc.md b/docs/ipc.md index 868de10..a503a87 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -95,6 +95,11 @@ This is what makes a user-space driver possible at all, and it's the subject of every capability is either well-known (the registry) or inherited — there's no way to delegate one. - **Asynchronous / buffered send** for the cases where a rendezvous is the wrong - shape (logging, notifications between servers). + 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.** `MSG_MAX` is 256 bytes and the copy runs under the big kernel lock; a bulk transfer wants shared pages, not a copy. diff --git a/docs/syscall.md b/docs/syscall.md index bbb10a9..6447b41 100644 --- a/docs/syscall.md +++ b/docs/syscall.md @@ -47,6 +47,8 @@ Everything else---including`read()`,`write()`,`malloc()`, and`fork()`---will run - **What it does:**Used strictly by your background user-space servers (like your disk driver or filesystem). It sends a reply to the last client that called it, and immediately puts the server to sleep until the next request arrives.[[1](https://news.ycombinator.com/item?id=33078441)] 3. **`Yield()`/`Thread_Ctrl()`** - **What it does:**Allows a thread to voluntarily give up its CPU time slice, or allows a root task to spawn/kill threads. +4. **`ipc_send(endpoint, message_buffer)`(Asynchronous Send)** + - **What it does:**Posts a small payload to an endpoint's bounded queue and returns *without* blocking — no rendezvous, no reply. The receiver picks it up through the same `IPC_ReplyWait`, as a buffered message. It is the async counterpart of `IPC_Call`, for one-to-many broadcasts where a synchronous rendezvous would let one dead or slow receiver hang the sender. The [input service](input.md) — keyboard-event fan-out — is its first user. A full queue drops the oldest message (a buffered message is discrete data, unlike a coalescing interrupt notification). * * * * * diff --git a/library/runtime/input.zig b/library/runtime/input.zig new file mode 100644 index 0000000..31020cb --- /dev/null +++ b/library/runtime/input.zig @@ -0,0 +1,117 @@ +//! User-space input helpers: the client and publisher sides of the input service, so a +//! program listening for keyboard events — or a driver broadcasting them — doesn't +//! hand-roll the IPC. Layered over `ipc` (endpoints, capability passing, `send`) and the +//! shared `input-protocol` wire format, the same way `device.zig` layers over the raw +//! `device_*` calls. See system/services/input/input.zig. +//! +//! A **subscriber** does: +//! var listener = input.subscribe() orelse return; +//! while (true) { const event = listener.next() orelse continue; ... } +//! +//! A **source** (keyboard driver) does: +//! var source = input.connectSource() orelse return; +//! _ = source.publish(.{ .kind = ..., .keycode = ..., ... }); + +const std = @import("std"); +const abi = @import("abi"); +const ipc = @import("ipc.zig"); +const system = @import("system.zig"); +const protocol = @import("input-protocol"); + +pub const KeyEvent = protocol.KeyEvent; +pub const EventKind = protocol.EventKind; +pub const Keycode = protocol.Keycode; + +/// 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. +fn lookupService() ?ipc.Handle { + var attempts: usize = 0; + while (attempts < 100) : (attempts += 1) { + if (ipc.lookup(.input)) |handle| return handle; + system.sleep(50); + } + return null; +} + +/// A subscription to the input service: our own endpoint, which the service pushes events +/// to. Keep it and call `next` in a loop. +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: [protocol.event_size]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 (a pure subscriber holds no client). + /// Returns null for any non-event wake-up (there should be none), so callers can loop. + pub fn next(self: *Subscriber) ?KeyEvent { + const got = ipc.replyWait(self.endpoint, &.{}, &self.receive, null); + if (!got.isMessage() or got.len < protocol.event_size) return null; + return std.mem.bytesToValue(KeyEvent, self.receive[0..protocol.event_size]); + } +}; + +/// Subscribe to keyboard events: create an endpoint for the service to push to, and hand +/// it over as a capability. Returns a `Subscriber` to loop `next` on, or null on failure +/// (the service never came up, out of handles, or the subscribe call failed). +pub fn subscribe() ?Subscriber { + const service = lookupService() orelse return null; + const endpoint = ipc.createIpcEndpoint() orelse return null; + + var request = protocol.Request{ .operation = @intFromEnum(protocol.Operation.subscribe), .event = undefined }; + var reply: [protocol.reply_size]u8 = undefined; + const result = ipc.callCap(service, std.mem.asBytes(&request), &reply, endpoint) catch return null; + if (result.len < protocol.reply_size) return null; + const header = std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]); + if (header.status != 0) return null; + return .{ .endpoint = endpoint }; +} + +/// A connection to the input service for a source (a keyboard driver) that publishes +/// events. Cheap to hold; `publish` is a short synchronous call the service answers at +/// once (its own fan-out to subscribers is asynchronous, so publishing never blocks on a +/// slow subscriber). +pub const Publisher = struct { + service: ipc.Handle, + + /// Broadcast one event to every subscriber. Returns false if the call to the service + /// failed (e.g. the service is gone). + pub fn publish(self: Publisher, event: KeyEvent) bool { + var request = protocol.Request{ .operation = @intFromEnum(protocol.Operation.publish), .event = event }; + var reply: [protocol.reply_size]u8 = undefined; + const len = ipc.call(self.service, std.mem.asBytes(&request), &reply) catch return false; + if (len < protocol.reply_size) return false; + return std.mem.bytesToValue(protocol.Reply, reply[0..protocol.reply_size]).status == 0; + } +}; + +/// Connect to the input service as an event source, waiting for it to come up. Returns a +/// `Publisher`, or null if the service never registered. +pub fn connectSource() ?Publisher { + return .{ .service = lookupService() orelse return null }; +} + +/// A tiny synthetic key-event generator, shared by the demo source and the keyboard +/// driver's placeholder stream while real scancode decoding is still a follow-up. `step` +/// is a monotonically increasing tick; the result rolls through the keys A..E, emitting +/// for each one a `key_down`, then a `key_press` carrying the character, then a `key_up`. +/// This is deliberately not wire protocol — it is scaffolding, so it lives with the +/// helpers, not in `input-protocol`. +pub fn syntheticEvent(step: usize) KeyEvent { + const Key = struct { code: Keycode, character: u32 }; + const keys = [_]Key{ + .{ .code = .a, .character = 'A' }, + .{ .code = .b, .character = 'B' }, + .{ .code = .c, .character = 'C' }, + .{ .code = .d, .character = 'D' }, + .{ .code = .e, .character = 'E' }, + }; + const key = keys[(step / 3) % keys.len]; + return switch (step % 3) { + 0 => .{ .kind = @intFromEnum(EventKind.key_down), .keycode = @intFromEnum(key.code), .character = 0, .modifiers = 0 }, + 1 => .{ .kind = @intFromEnum(EventKind.key_press), .keycode = @intFromEnum(key.code), .character = key.character, .modifiers = 0 }, + else => .{ .kind = @intFromEnum(EventKind.key_up), .keycode = @intFromEnum(key.code), .character = 0, .modifiers = 0 }, + }; +} diff --git a/library/runtime/ipc.zig b/library/runtime/ipc.zig index b17f9ba..462fca7 100644 --- a/library/runtime/ipc.zig +++ b/library/runtime/ipc.zig @@ -79,6 +79,16 @@ pub fn call(h: Handle, message: []const u8, reply: []u8) CallError!usize { return (try callCap(h, message, reply, null)).len; } +/// Post `message` to endpoint `h`'s asynchronous queue and return immediately — no +/// rendezvous, no reply, no blocking. The receiver picks it up through `replyWait` as a +/// buffered message (`Received.isMessage`). Unlike `call`, this **cannot hang on a dead +/// or slow peer**, which is why a broadcaster (the input service) delivers events this +/// way. The payload must fit an endpoint slot (64 bytes); a full queue drops the oldest +/// message. Returns false on failure (bad handle, oversized payload, bad buffer). +pub fn send(h: Handle, message: []const u8) bool { + return !failed(sc.systemCall3(.ipc_send, h, @intFromPtr(message.ptr), message.len)); +} + /// Set in `Received.badge` when what arrived is an asynchronous notification — a /// bound device interrupt — rather than a client's message. The low bits carry the /// GSI. See `isNotification`. @@ -89,6 +99,12 @@ pub const notify_badge_bit: u64 = abi.notify_badge_bit; /// rather than a device interrupt. The low bits carry the child's process id. pub const notify_exit_bit: u64 = abi.notify_exit_bit; +/// Set alongside `notify_badge_bit` when the wake-up is a **buffered message** — a payload +/// posted with `send` (`ipc_send`) — rather than a bare device interrupt or child-exit +/// notice. The payload is in the `replyWait` receive buffer (`Received.len` bytes); the +/// low bits of the badge carry the sender's task id. See `Received.isMessage`. +pub const notify_message_bit: u64 = abi.notify_message_bit; + /// The result of a `replyWait`: the request length, the sender's badge (a task id, or /// an IRQ notification if the high bit is set), and any capability the request carried. pub const Received = struct { @@ -109,6 +125,19 @@ pub const Received = struct { return self.isNotification() and self.badge & notify_exit_bit != 0; } + /// True if this wake-up is a **buffered message** posted with `send` (`ipc_send`): + /// there is a payload in the receive buffer (`self.len` bytes) and no reply is owed. + /// The subscriber side of a broadcast branches on this. + pub fn isMessage(self: Received) bool { + return self.isNotification() and self.badge & notify_message_bit != 0; + } + + /// The task id of whoever posted a buffered message, meaningful only when + /// `isMessage`. (The badge's low bits, with the three high marker bits masked off.) + pub fn senderTaskId(self: Received) u32 { + return @intCast(self.badge & ~(notify_badge_bit | notify_exit_bit | notify_message_bit)); + } + /// The interrupt source (a GSI), meaningful only when `isNotification` and /// not `isChildExit`. pub fn source(self: Received) u64 { diff --git a/library/runtime/runtime.zig b/library/runtime/runtime.zig index 10aba99..222eecf 100644 --- a/library/runtime/runtime.zig +++ b/library/runtime/runtime.zig @@ -16,6 +16,11 @@ pub const ipc = @import("ipc.zig"); pub const start = @import("start.zig"); /// The VFS wire protocol (shared with the VFS server). pub const vfs_protocol = @import("vfs-protocol"); +/// Keyboard-event listening (subscribe/next) and broadcasting (publish), over the input +/// service. See library/runtime/input.zig and system/services/input/. +pub const input = @import("input.zig"); +/// The input wire protocol (shared with the input service and its clients). +pub const input_protocol = @import("input-protocol"); /// POSIX-style file API: open/read/write/lseek/stat/close. /// C stdio: fopen/fread/fwrite/fseek/ftell/fclose over unistd. /// Device access for drivers: enumerate/claim/mmioMap. diff --git a/system/abi.zig b/system/abi.zig index 1000ab3..825039e 100644 --- a/system/abi.zig +++ b/system/abi.zig @@ -52,6 +52,7 @@ pub const SystemCall = enum(u64) { clock = 23, // clock() -> nanoseconds since boot: a monotonic time source (for timeouts/delays) process_enumerate = 24, // process_enumerate(buffer, maximum) -> total: snapshot the task table process_kill = 25, // process_kill(id) -> 0/-errno: end a process this process spawned + ipc_send = 26, // ipc_send(handle, message_ptr, message_len) -> 0/-errno: post a payload to an endpoint's async queue without blocking _, }; @@ -83,6 +84,15 @@ pub const notify_badge_bit: u64 = 1 << 63; /// notifications, which never set this bit). The microkernel's SIGCHLD. pub const notify_exit_bit: u64 = 1 << 62; +/// Set (alongside `notify_badge_bit`) in the badge of a **buffered message** — a payload +/// posted to an endpoint's async queue by `ipc_send`, delivered through `ipc_reply_wait` +/// like a notification (no reply owed) but carrying bytes in the receive buffer, not just +/// a badge. This is what distinguishes a payload-bearing async message from a bare IRQ / +/// child-exit notification (which sets neither this nor `notify_exit_bit`). The low bits +/// carry the sender's task id. The async counterpart of the synchronous `ipc_call`, for +/// broadcasts where a rendezvous is the wrong shape (the input service is the first user). +pub const notify_message_bit: u64 = 1 << 61; + /// Capacity of `ProcessDescriptor.name` — matches the longest name `system_spawn` /// accepts, so a process's recorded name (its argv[0]) is never truncated. pub const maximum_process_name = 64; @@ -113,6 +123,7 @@ pub const ProcessDescriptor = extern struct { /// during bring-up. The VFS server registers under `vfs`; clients look it up. pub const ServiceId = enum(u32) { vfs = 1, + input = 2, _, }; diff --git a/system/drivers/ps2-bus/keyboard.zig b/system/drivers/ps2-bus/keyboard.zig index 38649bb..48baf68 100644 --- a/system/drivers/ps2-bus/keyboard.zig +++ b/system/drivers/ps2-bus/keyboard.zig @@ -36,8 +36,21 @@ pub fn main() void { // served through the bus and does not claim the controller itself. _ = runtime.system.write("system/drivers/ps2-bus/keyboard: served by ps2-bus (controller owned by bus)\n"); + // Broadcast keyboard events through the input service so programs can listen for them + // (docs/input.md). Until the bus reads real IRQ1 scancodes and hands them here (a + // follow-up), we publish the same synthetic stand-in stream the demo source uses — the + // fan-out path is real, only the source of the bytes is placeholder. + var source = runtime.input.connectSource() orelse { + _ = runtime.system.write("system/drivers/ps2-bus/keyboard: input service unavailable\n"); + return; + }; _ = runtime.system.write("system/drivers/ps2-bus/keyboard: ok\n"); - while (true) runtime.system.sleep(1000); + + var step: usize = 0; + while (true) : (step +%= 1) { + _ = source.publish(runtime.input.syntheticEvent(step)); + runtime.system.sleep(200); + } } pub const panic = runtime.panic; diff --git a/system/kernel/ipc-synchronous.zig b/system/kernel/ipc-synchronous.zig index 90d6a0c..116f844 100644 --- a/system/kernel/ipc-synchronous.zig +++ b/system/kernel/ipc-synchronous.zig @@ -57,6 +57,29 @@ pub const EPERM: i64 = 9; // not permitted (process_kill by anyone but the super /// shared kernel↔user ABI (system/abi.zig), because ring 3 has to test the same bit. pub const notify_badge_bit: u64 = abi.notify_badge_bit; +/// Set (with `notify_badge_bit`) when a `replyWait` wake carries a buffered payload +/// posted by `send` (`ipc_send`), rather than a bare IRQ/exit notification. Shared with +/// ring 3 through the ABI so the receiver can tell "a message arrived" from "the hardware +/// spoke". +pub const notify_message_bit: u64 = abi.notify_message_bit; + +/// Largest payload a single `send` (`ipc_send`) may post. Kept small — the payload rides +/// inline in every `Endpoint`, and the async path is for events (a `KeyEvent` is 16 +/// bytes), not bulk transfer, which is what `call` and future shared pages are for. +pub const POST_MAXIMUM: usize = 64; + +/// Depth of an endpoint's async payload ring. Absorbs a burst while a receiver is briefly +/// busy; a full ring drops the *oldest* message (see `send`). +const post_capacity: usize = 16; + +/// One buffered message: a length-prefixed payload plus the sender's task id (delivered +/// in the low bits of the receiver's badge). +const PostSlot = struct { + length: u16 = 0, + sender_id: u64 = 0, + 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; @@ -74,6 +97,12 @@ pub const Endpoint = struct { notify_buffer: [8]u64 = undefined, notify_head: u8 = 0, notify_tail: u8 = 0, + // Pending buffered messages (payloads posted by `send`), a small FIFO ring. Unlike + // notifications — which are a level and coalesce — these are discrete messages, so a + // full ring drops the oldest rather than merging. + post_buffer: [post_capacity]PostSlot = undefined, + post_head: u16 = 0, + post_tail: u16 = 0, }; pub fn createIpcEndpoint() ?*Endpoint { @@ -265,12 +294,23 @@ pub fn replyWait(endpoint: *Endpoint, reply_ptr: u64, reply_len: u64, receive_pt scheduler.readyLocked(client); // its `call` now returns } - // (2) Receive the next request (or notification), blocking until one is ready. + // (2) Receive the next request (or notification / buffered message), blocking until + // one is ready. Bare notifications (IRQ/exit) come first — they're latency-sensitive + // and carry no payload — then buffered messages, then synchronous client requests. while (true) { if (popNotify(endpoint)) |badge| { out_badge.* = badge | notify_badge_bit; return 0; // notification: no payload, no reply owed, no cap } + if (popPost(endpoint)) |slot| { + const n = @min(@as(usize, slot.length), receive_cap); + // Copy from the kernel-resident ring slot (source aspace 0) into the receiver. + if (!copyAcross(0, @intFromPtr(&slot.bytes), me.aspace, receive_ptr, n)) { + continue; // bad receive buffer: drop this message, keep serving + } + out_badge.* = slot.sender_id | notify_badge_bit | notify_message_bit; + return @intCast(n); // async message: payload delivered, no reply owed, no cap + } if (dequeueSender(endpoint)) |caller| { const n = @min(caller.ipc_send_len, receive_cap); if (!copyAcross(caller.aspace, caller.ipc_send_ptr, me.aspace, receive_ptr, n)) { @@ -306,6 +346,44 @@ fn popNotify(endpoint: *Endpoint) ?u64 { return badge; } +/// Take the oldest buffered message from the post ring, or null if empty. Returns a +/// pointer into the endpoint's own storage — valid until the next `send`/`popPost` under +/// the same lock region, which is all the copy-out in `replyWait` needs. +fn popPost(endpoint: *Endpoint) ?*const PostSlot { + if (endpoint.post_head == endpoint.post_tail) return null; + const slot = &endpoint.post_buffer[endpoint.post_head % post_capacity]; + endpoint.post_head +%= 1; + return slot; +} + +/// Client-free side of async IPC (`ipc_send`): copy `[source_va, len)` from address space +/// `source_as` into `endpoint`'s post ring and wake a waiting receiver — **without +/// blocking the sender** and with no reply owed. `sender_id` rides along, delivered in the +/// low bits of the receiver's badge. Returns 0, or a negative errno (`-E2BIG` if the +/// payload exceeds `POST_MAXIMUM`, `-EFAULT` if the source buffer is unmapped / out of the +/// user half). A full ring drops the *oldest* message (advancing `post_head`), because a +/// buffered message is discrete, not a level: keeping the newest keeps input responsive. +/// Precondition: the big kernel lock is held. +pub fn sendLocked(endpoint: *Endpoint, source_as: u64, source_va: u64, len: u64, sender_id: u64) i64 { + if (len > POST_MAXIMUM) return -E2BIG; + // Drop the oldest if the ring is full, so this newest message always lands. + if (endpoint.post_tail -% endpoint.post_head >= post_capacity) endpoint.post_head +%= 1; + const slot = &endpoint.post_buffer[endpoint.post_tail % post_capacity]; + if (!copyFromUser(source_as, source_va, slot.bytes[0..@intCast(len)])) return -EFAULT; + slot.length = @intCast(len); + slot.sender_id = sender_id; + endpoint.post_tail +%= 1; + scheduler.wakeLocked(&endpoint.receive_wait_queue); + return 0; +} + +/// `sendLocked` wrapped in its own critical section, for the `ipc_send` syscall path. +pub fn send(endpoint: *Endpoint, source_as: u64, source_va: u64, len: u64, sender_id: u64) i64 { + const flags = sync.enter(); + defer sync.leave(flags); + return sendLocked(endpoint, source_as, source_va, len, sender_id); +} + /// Post an asynchronous notification carrying `badge` to `endpoint` and wake a waiting /// receiver. Precondition: the big kernel lock is held. /// diff --git a/system/kernel/process.zig b/system/kernel/process.zig index f3f2b5b..db6af84 100644 --- a/system/kernel/process.zig +++ b/system/kernel/process.zig @@ -183,6 +183,7 @@ fn system_call(state: *architecture.CpuState) void { .ipc_lookup => systemIpcLookup(state), .ipc_call => systemIpcCall(state), .ipc_reply_wait => systemIpcReplyWait(state), + .ipc_send => systemIpcSend(state), .device_enumerate => systemDeviceEnumerate(state), .device_claim => systemDeviceClaim(state), .mmio_map => systemMmioMap(state), @@ -263,6 +264,18 @@ fn systemIpcReplyWait(state: *architecture.CpuState) void { architecture.setSystemCallResult3(state, received_cap); } +/// ipc_send(handle, message_ptr, message_len) -> 0/-errno: post a payload to an +/// endpoint's async queue and wake a receiver, without blocking the caller. The async +/// counterpart of ipc_call — for broadcasts (the input service) where a rendezvous would +/// let one dead subscriber hang the sender. Delivered through ipc_reply_wait as a +/// buffered message (badge carries notify_message_bit and the caller's task id). +fn systemIpcSend(state: *architecture.CpuState) void { + const me = scheduler.current(); + const endpoint = ipc.resolveHandle(me, architecture.systemCallArg(state, 0)) orelse return failErr(state, ipc.EBADF); + const r = ipc.send(endpoint, me.aspace, architecture.systemCallArg(state, 1), architecture.systemCallArg(state, 2), me.id); + architecture.setSystemCallResult(state, @bitCast(r)); +} + /// device_enumerate(buffer, maximum) -> total: snapshot the device table into the caller's /// buffer (up to `maximum` entries), returning the total device count. fn systemDeviceEnumerate(state: *architecture.CpuState) void { diff --git a/system/kernel/tests.zig b/system/kernel/tests.zig index 8c4e22e..fc65306 100644 --- a/system/kernel/tests.zig +++ b/system/kernel/tests.zig @@ -136,6 +136,8 @@ pub fn run(case: []const u8, boot_information: *const BootInformation) void { initialRamdiskTest(boot_information); } else if (eql(case, "vfs")) { vfsTest(boot_information); + } else if (eql(case, "input")) { + inputTest(boot_information); } else if (eql(case, "hpet")) { hpetTest(boot_information); } else if (eql(case, "iopass")) { @@ -1585,6 +1587,50 @@ fn vfsTest(boot_information: *const BootInformation) void { result(); } +/// The full input path: spawn the input service, a synthetic keyboard source, and a +/// subscriber from the initial_ramdisk. The source publishes key events; the service +/// broadcasts them (with the asynchronous ipc_send); the subscriber receives them and — +/// only once it has — heartbeats "input-test: ok". Seeing that marker proves an event +/// travelled source -> service -> subscriber over IPC, exercising the async buffered-send +/// primitive and capability-passing subscription. The source and service stay silent +/// after startup so the subscriber's line is the one left in the shared evidence buffer. +fn inputTest(boot_information: *const BootInformation) void { + log("DANOS-TEST-BEGIN: input\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.write_count = 0; + process.write_from_user = false; + _ = 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 + + // Wait for the subscriber's success heartbeat (it beats once per received event). + const prefix = "input-test: ok"; + scheduler.setPriority(1); + const deadline = architecture.millis() + 12000; + while (architecture.millis() < deadline) { + if (process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix) and process.write_count >= 2) break; + scheduler.yield(); + } + scheduler.setPriority(4); + + const ok = process.write_len >= prefix.len and eql(process.write_buffer[0..prefix.len], prefix); + check("a subscriber received a broadcast key event over IPC (source -> service -> subscriber)", ok); + check("events kept flowing (service + async send stay up)", process.write_count >= 2); + check("client syscalls came from user mode (CPL 3)", process.write_from_user); + result(); +} + /// Process arguments, end to end: spawn args-echo bare (its argv[0] is the /// initial-ramdisk name). Instance 1 sees argc == 1 and respawns itself through /// `system_spawn` with the extra arguments "alpha beta-42" — the syscall argument diff --git a/system/services/init/init.zig b/system/services/init/init.zig index e9f656c..461a2b4 100644 --- a/system/services/init/init.zig +++ b/system/services/init/init.zig @@ -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 diff --git a/system/services/input-source/input-source.zig b/system/services/input-source/input-source.zig new file mode 100644 index 0000000..d819cf0 --- /dev/null +++ b/system/services/input-source/input-source.zig @@ -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; +} diff --git a/system/services/input-test/input-test.zig b/system/services/input-test/input-test.zig new file mode 100644 index 0000000..9c652fb --- /dev/null +++ b/system/services/input-test/input-test.zig @@ -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; +} diff --git a/system/services/input/input.zig b/system/services/input/input.zig new file mode 100644 index 0000000..9d497fc --- /dev/null +++ b/system/services/input/input.zig @@ -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; +} diff --git a/system/services/input/protocol.zig b/system/services/input/protocol.zig new file mode 100644 index 0000000..eb95c70 --- /dev/null +++ b/system/services/input/protocol.zig @@ -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)"); +} diff --git a/test/qemu_test.py b/test/qemu_test.py index 982a43f..701ac1b 100644 --- a/test/qemu_test.py +++ b/test/qemu_test.py @@ -250,6 +250,12 @@ CASES = [ {"name": "vfs", "expect": r"DANOS-TEST-RESULT: PASS", "fail": r"DANOS-TEST-RESULT: FAIL"}, + # The input service: a synthetic keyboard source publishes events, the service + # broadcasts them over the async ipc_send primitive, and a subscriber (which joined by + # passing its endpoint as a capability) receives them — source -> service -> subscriber. + {"name": "input", + "expect": r"DANOS-TEST-RESULT: PASS", + "fail": r"DANOS-TEST-RESULT: FAIL"}, # IO passthrough + IRQ-as-IPC: a user-space HPET driver maps device MMIO into # its own address space, binds the device's interrupt to an IPC endpoint, and # is woken by the hardware five times while blocked (never polling).