Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
17 KiB
INPUTD SCHEME API ENHANCEMENT DESIGN
Target: recipes/core/base/source/drivers/inputd
Scope: Userspace-only inputd scheme enhancement
Date: 2026-04-13
1. Goal
Enhance inputd so it can do all of the following without breaking any existing callers:
- Let producers register under stable names such as
ps2-keyboard,ps2-mouse, orusb-hid0. - Expose per-device consumer streams so services such as
evdevdcan subscribe to one device only. - Publish hotplug notifications for device add/remove.
- Expose currently registered devices through the scheme root directory.
This is an additive design. Existing paths, existing event payloads, existing VT behavior, and existing display/control behavior must continue to work unchanged.
2. Current Implementation Summary
The current inputd implementation in recipes/core/base/source/drivers/inputd/src/main.rs has these important properties:
Handleonly supportsProducer,Consumer,Display,Control, andSchemeRoot.openat()only recognizesproducer,consumer,consumer_bootlog,handle,handle_early, andcontrol.- All producers write anonymous
orbclient::Eventbytes into the sameHandle::Producerpath. - Legacy consumers are per-VT handles.
write()only delivers input bytes to the active VT consumer set. SchemeRootexists, but it is not a real directory yet: it does not enumerate entries.lib.rsonly exposesProducerHandle,ConsumerHandle,DisplayHandle, andControlHandle.
Current callers confirm the limitation:
ps2dopens oneProducerHandleand sends both keyboard and mouse events into the same stream.usbhiddalso opens oneProducerHandleand sends keyboard/mouse/button/scroll data into the same stream.- local
evdevdreads/scheme/input/consumer, receives anonymous mixedorbclient::Eventvalues, and manually translates them.
3. Design Principles
- Keep legacy behavior intact:
/scheme/input/producerand/scheme/input/consumermust keep working exactly as they do today. - Do not change event payloads: device-specific streams still carry serialized
orbclient::Eventvalues. - Keep all logic in userspace: no kernel changes, no new kernel scheme semantics.
- Make enumeration path-driven: device names are visible as entries below
/scheme/input/. - Use explicit hotplug events: device discovery and liveness must not depend on polling failed opens.
4. Scheme Path Layout
The enhanced namespace is:
/scheme/input/ — SchemeRoot (directory listing)
/scheme/input/producer — Legacy producer (unchanged)
/scheme/input/producer/{name} — Named producer: ps2-keyboard, ps2-mouse, usb-hid0
/scheme/input/consumer — Legacy consumer (unchanged)
/scheme/input/{device_name} — Per-device consumer: reads events from one named producer
/scheme/input/events — Hotplug event stream
/scheme/input/handle/{display} — Display handle (unchanged)
/scheme/input/control — Control commands (unchanged)
Legacy-only paths that must remain valid even though they are not part of the new API surface:
/scheme/input/consumer_bootlog — Existing bootlog VT consumer
/scheme/input/handle_early/{display} — Existing early framebuffer handoff path
4.1 Root Directory Listing
SchemeRoot should become a real directory endpoint backed by getdents, not by overloading read().
The root listing should expose:
- static entries:
producer,consumer,consumer_bootlog,events,handle,handle_early,control - one dynamic entry per registered device name from
devices
That keeps the namespace honest while still allowing device enumeration from /scheme/input/.
InputDeviceLister in lib.rs should filter out the reserved static names and return only dynamic device entries.
5. Handle Model
The Handle enum in main.rs should become:
enum Handle {
Producer,
NamedProducer {
name: String,
},
Consumer {
events: EventFlags,
pending: Vec<u8>,
needs_handoff: bool,
notified: bool,
vt: usize,
},
DeviceConsumer {
device_name: String,
events: EventFlags,
pending: Vec<u8>,
notified: bool,
},
HotplugEvents {
events: EventFlags,
pending: Vec<u8>,
notified: bool,
},
Display {
events: EventFlags,
pending: Vec<VtEvent>,
notified: bool,
device: String,
is_earlyfb: bool,
},
Control,
SchemeRoot,
}
Notes:
Producerremains the legacy anonymous producer path.NamedProduceronly needs the registered name. Device ID lookup stays in shared scheme state.DeviceConsumeris byte-oriented like the legacy consumer, but without VT or handoff state.HotplugEventsstores serialized variable-length hotplug records inpending.SchemeRootremains a dedicated handle variant, but now supports directory enumeration.
6. Scheme Open Semantics
openat() should parse paths as follows:
6.1 Existing Paths
producerwith no child component →Handle::Producerconsumer→ current VT consumer allocation logicconsumer_bootlog→ current VT 1 logichandle/{display}→ unchangedhandle_early/{display}→ unchangedcontrol→ unchanged
6.2 New Paths
producer/{name}→Handle::NamedProducer { name }events→Handle::HotplugEvents { ... }- any other top-level non-reserved path component →
Handle::DeviceConsumer { device_name, ... }
6.3 Name Validation
Named producer registration must reject:
- empty names
- names containing
/ - reserved names:
producer,consumer,consumer_bootlog,events,handle,handle_early,control - duplicate live names already present in
devices
Recommended error behavior:
- invalid name →
EINVAL - duplicate name →
EEXIST - open of
/scheme/input/{device_name}for a currently unknown device →ENOENT
7. State Management
InputScheme should add:
devices: BTreeMap<String, u32>,
next_device_id: AtomicUsize,
Purpose:
devicesmaps device name → current device IDnext_device_idallocates monotonically increasing IDs
Behavior:
-
When
NamedProduceropens successfully:- allocate
device_id = next_device_id.fetch_add(1, Ordering::SeqCst) as u32 - insert
devices.insert(name.clone(), device_id) - serialize a
DEVICE_ADDhotplug message - append it to every
Handle::HotplugEvents.pending - set
notified = falseon those hotplug handles - set
has_new_events = true
- allocate
-
When
NamedProducercloses:- remove the entry from
devices - serialize a
DEVICE_REMOVEhotplug message with the removed ID and name - append it to every
Handle::HotplugEvents.pending - set
notified = false - set
has_new_events = true
- remove the entry from
-
Device IDs are never reused. If
ps2-keyboarddisappears and later comes back, it gets a newdevice_id.
No additional kernel state is required. This is ordinary daemon-side bookkeeping.
8. Event Routing Logic
The existing preprocessing path in write() must remain in place:
- special Super+Fn VT switching behavior stays in
inputd - keymap translation still happens in
inputd - the emitted payload remains serialized
orbclient::Event
After that preprocessing step, routing changes as follows.
8.1 Legacy Producer
Input written to /scheme/input/producer follows the current legacy route:
- deliver to the existing legacy consumer path
- preserve current active-VT behavior
- do not deliver to any
DeviceConsumer - do not generate hotplug events
8.2 Named Producer
Input written to /scheme/input/producer/{name} must be fanned out to:
- the matching
DeviceConsumerhandles wheredevice_name == name - the existing legacy consumer path used by older display/input clients
That means named producers are supersets of legacy routing, not replacements.
8.3 Device Consumer
/scheme/input/{device_name} only receives events from the named producer with the exact same name.
It must never receive:
- anonymous legacy producer traffic
- events from other named producers
- display or control events
8.4 Routing Sketch
legacy producer write
-> existing input normalization
-> legacy VT consumer fan-out only
named producer write(name)
-> existing input normalization
-> device consumers for name
-> legacy VT consumer fan-out
Implementation-wise, the simplest approach is:
- detect whether the writer is
ProducerorNamedProducer { name } - run the existing event transformation code once
- serialize transformed
Eventvalues once - if named, append to matching
DeviceConsumer.pending - append to the legacy consumer path using the current active-VT logic
- clear
notifiedon affected readers and sethas_new_events = true
9. Hotplug Event Stream
/scheme/input/events is a read-only stream of variable-length hotplug records.
9.1 Binary Format
#[repr(C)]
struct InputHotplugEvent {
kind: u32, // 1 = DEVICE_ADD, 2 = DEVICE_REMOVE
device_id: u32, // Unique device identifier
name_len: u32, // Length of device name
_reserved: u32, // Future use
}
// Followed by name_len bytes of UTF-8 device name
Constants:
const DEVICE_ADD: u32 = 1;
const DEVICE_REMOVE: u32 = 2;
9.2 Stream Semantics
- The stream is append-only and ordered by daemon observation.
- Each record is serialized as header bytes followed by UTF-8 name bytes.
read()drains raw bytes frompending.- Because records are variable-length, callers must handle partial reads.
HotplugHandleinlib.rsshould hide this by buffering partial bytes until one full record is available.
9.3 Notification Model
Handle::HotplugEvents participates in fevent(EVENT_READ) exactly like other readable handles:
- when at least one serialized hotplug record is pending and the handle is subscribed to
EVENT_READ, post a read event - after a successful read drains the buffer, notification becomes edge-triggered again
10. Scheme Root Enumeration
Enumeration should be implemented with getdents() on Handle::SchemeRoot.
Recommended behavior:
scheme_root()still creates aHandle::SchemeRootgetdents()emits static entries plus one entry perdeviceskeyread()onSchemeRootstays invalid (EBADForEISDIRare both acceptable if applied consistently)openat()continues to require a validSchemeRootdirfd
Example visible entries after ps2d registers keyboard and mouse:
producer
consumer
consumer_bootlog
events
handle
handle_early
control
ps2-keyboard
ps2-mouse
This gives normal filesystem-style discovery while keeping old endpoints visible.
11. lib.rs Public API Changes
The public API should be extended, not replaced.
11.1 Existing Types Stay
ProducerHandleConsumerHandleDisplayHandleControlHandle
Their existing constructors and behavior remain unchanged.
11.2 New Types
pub struct NamedProducerHandle(File);
pub struct DeviceConsumerHandle(File);
pub struct HotplugHandle {
file: File,
partial: Vec<u8>,
}
#[derive(Debug, Clone)]
#[repr(C)]
pub struct HotplugEventHeader {
pub kind: u32,
pub device_id: u32,
pub name_len: u32,
pub reserved: u32,
}
#[derive(Debug, Clone)]
pub struct HotplugEvent {
pub kind: u32,
pub device_id: u32,
pub name: String,
}
pub struct InputDeviceLister;
11.3 Constructors
impl NamedProducerHandle {
pub fn new(name: &str) -> io::Result<Self>;
}
impl DeviceConsumerHandle {
pub fn new(device_name: &str) -> io::Result<Self>;
}
impl HotplugHandle {
pub fn new() -> io::Result<Self>;
}
Path mapping:
NamedProducerHandle::new("ps2-keyboard")→/scheme/input/producer/ps2-keyboardDeviceConsumerHandle::new("ps2-keyboard")→/scheme/input/ps2-keyboardHotplugHandle::new()→/scheme/input/events
11.4 Read/Write Shape
Recommended API shape:
impl NamedProducerHandle {
pub fn write_event(&mut self, event: orbclient::Event) -> io::Result<()>;
}
pub enum DeviceConsumerHandleEvent<'a> {
Events(&'a [Event]),
}
impl DeviceConsumerHandle {
pub fn event_handle(&self) -> BorrowedFd<'_>;
pub fn read_events<'a>(&self, events: &'a mut [Event])
-> io::Result<DeviceConsumerHandleEvent<'a>>;
}
impl HotplugHandle {
pub fn event_handle(&self) -> BorrowedFd<'_>;
pub fn read_event(&mut self) -> io::Result<Option<HotplugEvent>>;
}
DeviceConsumerHandle deliberately mirrors ConsumerHandle, but it does not need Handoff support because VT display handoff is unrelated to per-device streams.
11.5 Device Enumeration Helper
InputDeviceLister should provide a safe wrapper around scheme-root directory reads, for example:
impl InputDeviceLister {
pub fn list() -> io::Result<Vec<String>>;
}
Behavior:
- read
/scheme/input/as a directory - drop reserved static entries
- return only currently registered device names
This keeps callers out of scheme-internal filtering logic.
12. Producer Lifecycle and Consumer Behavior
12.1 Named Producer Registration
Opening /scheme/input/producer/{name} is both:
- creation of a producer handle
- registration of
{name}as a live device
Closing the fd unregisters the device.
This matches current scheme style well because inputd already uses on_close() to clean up VT consumers.
12.2 Device Consumer Lifetime
Per-device consumer handles are name-based subscriptions.
- open succeeds only while the device name is currently registered
- once open, the handle remains attached to that name
- if the producer disappears, no more events arrive for that handle
- if the same name is registered again later, the handle resumes receiving events for that name
- the hotplug stream is how clients notice that the underlying producer instance changed
This keeps DeviceConsumer simple and avoids introducing a second handle teardown protocol.
13. Migration Path
13.1 ps2d
ps2d is the first caller that should adopt the new API because it already has a clean split between keyboard and mouse sources.
Recommended startup logic:
- Try
NamedProducerHandle::new("ps2-keyboard") - Try
NamedProducerHandle::new("ps2-mouse") - If both succeed, run in named mode
- If either fails, close any partially opened named handle and fall back to one legacy
ProducerHandle::new()
Routing:
- keyboard scancodes →
ps2-keyboard - mouse move / absolute move / button / scroll events →
ps2-mouse
This preserves compatibility with old inputd while immediately enabling per-device consumers on new inputd.
13.2 evdevd
Once the scheme exists, local evdevd can move from /scheme/input/consumer to:
InputDeviceLister::list()to discover devicesDeviceConsumerHandle::new(name)for device-local streamsHotplugHandle::new()to watch add/remove
It can keep the legacy consumer path as a fallback for older systems.
13.3 usbhidd
usbhidd can remain legacy initially, then later migrate to named producers such as usb-hid0, usb-hid1, or more specific per-interface names.
14. Backward Compatibility Requirements
All of the following must continue to work unchanged:
/scheme/input/producer/scheme/input/consumer/scheme/input/consumer_bootlog/scheme/input/handle/{display}/scheme/input/handle_early/{display}/scheme/input/control- current
ProducerHandle,ConsumerHandle,DisplayHandle, andControlHandleAPIs - current active-VT routing and graphics handoff behavior
Compatibility rules:
- Old producers still emit anonymous events into the legacy stream.
- Old consumers still receive the same event format and VT behavior.
- New named producers additionally feed the legacy stream, so old consumers continue to see those events.
- No caller is forced to understand hotplug or enumeration.
15. Non-Goals
This design does not include:
- capability discovery (
keyboardvsmousemetadata) - kernel support or syscall ABI changes
- replacing
orbclient::Eventwith a new event format - changing VT ownership, display handoff, or control command semantics
- automatic migration of existing daemons
16. Implementation Checklist
Another developer implementing this design should be able to proceed in this order:
- extend
HandleandInputSchemestate - teach
openat()to parseproducer/{name},events, and dynamic device names - add root
getdents()support forSchemeRoot - refactor
write()so producer type is detected before routing - fan out named-producer events to matching
DeviceConsumerhandles and the existing legacy path - add hotplug queue serialization helpers
- extend
fevent()and daemon notification loop forDeviceConsumerandHotplugEvents - add cleanup in
on_close()forNamedProducer - extend
lib.rswith the new handle types and directory lister - migrate
ps2dwith a named-producer-first, legacy-fallback strategy
17. Final Outcome
After this enhancement:
- Legacy consumers continue to work as-is.
ps2dand future drivers can publish stable device names.evdevdand similar services can subscribe to exactly one device stream.- userspace can enumerate live input devices and react to hotplug events.
That solves the current anonymity problem without changing the kernel or breaking the existing Redox input stack.