Generalize input module to mouse and joystick/gamepad events

Extend the input service beyond the keyboard so mouse and joystick/gamepad
drivers can broadcast too, with per-device publish and subscribe methods.

- protocol: KeyEvent joins MouseEvent (motion/buttons/scroll) and
  JoystickEvent (axes/buttons), all carried in a common InputEvent envelope
  tagged with a DeviceKind. A subscribe request carries a device_mask, so a
  subscriber names the classes it wants and the service routes each event only
  to interested subscribers (a mouse-only listener never wakes for keystrokes).
- runtime: per-device publish methods (publishKeyboardEvent/publishMouseEvent/
  publishJoystickEvent) and subscribe helpers (subscribeKeyboard/Mouse/Joystick,
  each typed, plus subscribe(mask)/subscribeAll returning the tagged envelope).
- service: subscriber table gains a device_mask; broadcast routes by the
  event's device class.
- mouse driver now publishes (synthetic) mouse events like the keyboard driver;
  input-source cycles all three classes; input-test subscribes to all and only
  emits its "ok" marker once it has received one of each class — so the passing
  test proves per-device routing, not just delivery. Real HID decoding stays a
  follow-up.

No kernel changes: ipc_send is generic and the 36-byte InputEvent fits its
64-byte payload. Full QEMU suite 48/48; serial log confirms keyboard, mouse,
and joystick all reach one subscription.
This commit is contained in:
Daniel Samson
2026-07-11 15:21:09 +01:00
parent 65244e3103
commit 1bf91115dd
10 changed files with 457 additions and 160 deletions
+66 -40
View File
@@ -1,16 +1,33 @@
# The input module: broadcasting keyboard events
# 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 driver and the listeners sits the **input service**
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 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.
it fans each event out to every interested 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.
## 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](../system/services/input/protocol.zig)). Each class has its own typed
event:
- `KeyEvent` — `key_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`.
- `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.
## Why this needed a new kernel primitive
@@ -54,32 +71,36 @@ This is the async counterpart of `ipc_call`, and the input service is its first
## 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)
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 sub_ep cap └─ replyWait(ep)
(from the call's capability) → KeyEvent
subscribe → store {ep cap, └─ replyWait(ep)
task id, device_mask} → InputEvent
```
- A **subscriber** calls `input.subscribe()`
([library/runtime/input.zig](../library/runtime/input.zig)): it creates its own endpoint
- 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/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.
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](../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.
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
@@ -87,13 +108,16 @@ 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.
- **Synthetic sources, for now.** The `ps2-bus` driver owns PNP0303, which carries *both*
the 0x60/0x64 ports and IRQ1, so reading real scancodes/packets has to live in the bus,
not in [keyboard.zig](../system/drivers/ps2-bus/keyboard.zig) /
[mouse.zig](../system/drivers/ps2-bus/mouse.zig). Until that lands, the keyboard driver
publishes a synthetic key stream, the mouse driver a synthetic motion/click stream, and
the hardware-free `input-source` rotates through all three classes (including a synthetic
joystick, which has no driver yet) — all via the `input.synthetic*Event` helpers. The
fan-out and per-device routing are real; only the bytes are placeholder. **Follow-up:** the
bus binds IRQ1/IRQ12, reads port 0x60, and `ps2-library` decodes scan-set-1 →
keycodes and mouse packets; the drivers publish 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
@@ -104,9 +128,11 @@ the service delivers to its endpoint, which only the same thread could receive).
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.
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