danos/docs/device-driver-development/input.md

12 KiB

The input module: broadcasting input 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 drivers 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 interested subscriber. It is an ordinary ring-3 process reached over IPC, like the FAT server — no kernel knows what a key is.

One service, several device classes

The service carries three device classes today — keyboard, mouse, and joystick/gamepad — and is built to take more (protocol.zig). Each class has its own typed event:

  • KeyEventkey_down/key_up (physical make/break) and key_press (a character was produced, carrying the Unicode scalar); plus a layout-independent keycode and a modifiers bitmask.
  • MouseEvent — relative motion (dx/dy), button_down/button_up, and scroll.
  • JoystickEventaxis moves (a signed value on a control index) and button_down/button_up.

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), 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

The interesting part is delivery, and it runs straight into the shape of danos IPC. 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 a subscriber's endpoint is an unregistered capability the kernel's death path cannot reach (since display v2's V6, killOwnedEndpointsLocked in ipc-synchronous.zig marks a dead owner's registered endpoints dead and wakes parked callers with -EPEER — but unregistered ones just drop with the task's handle table). 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 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 messagenotify_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 (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/mouse driver, input-source       input service               subscriber(s)
  -----------------------------------       -------------               -------------
  connectSource(); loop:                    replyWait:                  subscribeKeyboard()/…All:
    publishKeyboardEvent(k) ─ ipc_call ─▶  publish → broadcast:            createIpcEndpoint()
    publishMouseEvent(m)                     for each sub whose            callCap(subscribe,
    publishJoystickEvent(j)                   mask matches event.device:     send_cap = ep,
                                               ipc_send(sub_ep) ──────▶       device_mask)
                                       reply ok                          loop: next()
                                     subscribe → store {ep cap,          └─ replyWait(ep)
                                       task id, device_mask}                → InputEvent
  • A subscriber calls input.subscribe(mask) — or a typed helper: subscribeKeyboard(), subscribeMouse(), subscribeJoystick() (one class, next() returns the decoded event), or subscribeAll() (every class, next() returns a tagged InputEvent) (library/client/input/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), along with its device_mask. Then it loops on next(), a replyWait on that endpoint returning each pushed event.
  • A source (a keyboard, mouse, or joystick driver) calls input.connectSource() and the method for its class: publishKeyboardEvent, publishMouseEvent, or 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) 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), 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_sends 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), 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 the service delivers to its endpoint, which only the same thread could receive).

Status and follow-ups

  • The keyboard is real. The ps2-bus driver owns PNP0303, which carries both the 0x60/0x64 ports and IRQ1, so reading the hardware lives in the bus, not in keyboard.zig: the bus binds IRQ1 and, on each interrupt, drains port 0x60, routing every byte by the status register's auxiliary-output bit to whichever child driver attached for that device (an AttachRequest to the well-known ps2_bus service, carrying the child's endpoint as a capability; the bytes then arrive as asynchronous ForwardedByte messages, so the IRQ path never blocks on a child). The keyboard driver decodes the stream — scancode set 2, what the keyboard sends with the 8042's legacy translation off, decoded by scancode.zig into USB HID usage keycodes with make/break, typematic-repeat, and modifier tracking (host-tested under zig build test) — and publishes real key_down/key_press/key_up events.
  • Keycode → character is wired in: the keyboard driver fills a key_press event's character through library/xkeyboard-config (xkb.map(layout, keycode, mods) → keysym + Unicode character), synthesizing the ASCII control characters for Enter/Tab/Backspace/Escape, whose keysyms map to no Unicode. The layout defaults to us; the bus can pass another as the driver's argv[2] — the seam for a future settings source.
  • The mouse is real too. IRQ12 is enumerated on the auxiliary device's own ACPI node (PNP0F13), so the bus claims that node alongside the controller and routes both IRQs to its one endpoint, acking whichever line the notification's badge names. mouse.zig attaches the way the keyboard does and assembles the forwarded bytes with mouse-packet.zig (three-byte stream-mode packets: sync/overflow handling, nine-bit movement, screen-convention dy — host-tested under zig build test) into button_down/button_up transitions and motion events. Follow-up: the IntelliMouse magic-knock for a scroll wheel (four-byte packets) and scroll events. The hardware-free input-source still rotates through all three classes synthetically (including a joystick, which has no driver yet) via the input.synthetic*Event helpers.
  • 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). A source capability is future work.

Verifying it

The input case (python3 test/qemu_test.py input, in tests.zig inputTest) boots the real kernel and spawns the service, the synthetic source (which cycles keyboard, mouse, and joystick events), and a subscriber that took all three classes. It passes only when the subscriber heartbeats input-test: ok — proof that an event travelled source → service → subscriber over IPC, exercising ipc_send, capability-passing subscription, and per-device routing. Each serial line names the class received, so the log shows all three arriving on one stream.

See also

  • ipc.md — the synchronous rendezvous and the notification path ipc_send extends.
  • syscall.md — the system-call surface, including ipc_send.
  • driver-model.md — class drivers, capability passing (M13), the trust model.