[GH-ISSUE #16] Offer: multi-seat Incus test rig, plus findings on the Incus strategy and uhid #15

Open
opened 2026-10-30 21:32:33 +00:00 by bsakaguchi · 1 comment
Owner

Originally created by @superuser404notfound on GitHub (Jul 28, 2026).
Original GitHub issue: https://github.com/joleuger/vuinputd/issues/16

Following up from https://github.com/LizardByte/Sunshine/issues/3768, where you
mentioned uhid is the missing piece. I read through the codebase and the open
issues first, so this is partly an offer of help and partly two concrete
findings that might be useful regardless of whether I end up contributing code.

Where I am coming from. I run a multi-seat setup on Incus: two seats, each
an Incus system container with headless sway, its own Sunshine instance and its
own Steam account, streaming to separate Moonlight clients. Both run in parallel
today with keyboard, mouse and gamepad reaching exactly one seat each. My input
isolation is the naive one, host udev rules plus a broker that injects nodes
into the right container by matching the seat tag Sunshine writes into the
device name. It works, but attribution by name only protects against accidents,
not against a compromised seat, which is exactly the axis where mediating
/dev/uinput is better. Code and measurement logs:
https://github.com/superuser404notfound/Polyseat


1. The Incus strategy cannot do multi-container today (relates to #11)

Every generic strategy derives its target from requesting_process and acts in
that process's namespaces, so multiple containers work by construction.
impl InjectionStrategy for Incus does the opposite: it marks the parameter
_requesting_process as unused and takes the container name from
get_scope(), bailing out on Scope::Multi. So Incus effectively requires one
vuinputd instance per container, and a setup like mine cannot be served by a
single daemon.

The container name is derivable from the requesting process. On a live two-seat
host here, reading the caller's cgroup gives it directly:

$ cat /proc/52144/cgroup
0::/lxc.payload.seat1/user.slice/user-1000.slice/user@1000.service/app...
$ cat /proc/71439/cgroup
0::/lxc.payload.seat2/user.slice/user-1000.slice/user@1000.service/app...

That is Incus 7.2 on a CachyOS host, where the prefix is lxc.payload. because
Incus still uses LXC underneath; other versions may write incus.payload., so
it would want matching on both. Either way it would let the Incus strategy
resolve the target the same way the generic ones do and drop the Scope::Single
restriction.

While reading that function I also noticed mknod_device_node builds
hostpath and incuspath as the same path=... string and passes both, never
setting source=. Incus tolerates it because path doubles as the host path
when source is absent, so this is cosmetic rather than broken, but it reads
like one of the two was meant to be source=.

2. What I measured about uhid

Sunshine creates gamepads through inputtino as HID devices via /dev/uhid, not
through uinput, and it does so for the default emulated model as well, not
only for a DualSense. So you do not need to buy a controller to reproduce it:
any Moonlight client with an on-screen controller overlay is enough. Without
uhid passed through, Sunshine still logs Gamepad 0 will be Xbox One controller
while no device ever appears inside the container, which is what made it look
like a client-side problem to me for quite a while.

A uhid device also produces a hidraw node alongside the input event node, and
Steam Input uses hidraw. So whatever handles uhid has to inject hidraw too. That
is an open gap on my side as well.

On the protocol: uinput is essentially write-only from the application, while
uhid is bidirectional and stateful. The application writes UHID_CREATE2 and
UHID_INPUT2 but has to read UHID_START, UHID_OPEN, UHID_OUTPUT and
UHID_GET_REPORT back. Having seen vuinput_read.rs and vuinput_poll.rs, the
relaying shape clearly already exists, so my question is not whether it fits but
how you would want it cut: a second CUSE device for /dev/uhid next to the
existing one, or something more shared?

Worth noting that this overlaps with #10: UHID_OUTPUT is how rumble arrives
for HID devices, so uhid support and the vibration work are partly the same
problem.

3. What I can actually offer

A real two-seat Incus rig with Sunshine, gamepads and the habit of writing down
what each experiment measured. I can test branches against it, and I can
reproduce #11, #10 and #7. #7 in particular looks familiar: Sunshine's "Mouse
passthrough (absolute)" reports EV_ABS rather than EV_REL, and classifying it
wrongly made sway treat it as a keyboard in my setup, so the pointer stayed dead
without an error appearing anywhere.

What I am not offering is a promise to land uhid support on my own in a codebase
I have read but never written in. If you want it from outside, tell me how you
would want it cut and I will start with a narrow piece. If you would rather build
it yourself, testing and diagnosis are probably where I am more useful anyway.

Originally created by @superuser404notfound on GitHub (Jul 28, 2026). Original GitHub issue: https://github.com/joleuger/vuinputd/issues/16 Following up from https://github.com/LizardByte/Sunshine/issues/3768, where you mentioned uhid is the missing piece. I read through the codebase and the open issues first, so this is partly an offer of help and partly two concrete findings that might be useful regardless of whether I end up contributing code. **Where I am coming from.** I run a multi-seat setup on Incus: two seats, each an Incus system container with headless sway, its own Sunshine instance and its own Steam account, streaming to separate Moonlight clients. Both run in parallel today with keyboard, mouse and gamepad reaching exactly one seat each. My input isolation is the naive one, host udev rules plus a broker that injects nodes into the right container by matching the seat tag Sunshine writes into the device name. It works, but attribution by name only protects against accidents, not against a compromised seat, which is exactly the axis where mediating `/dev/uinput` is better. Code and measurement logs: https://github.com/superuser404notfound/Polyseat --- ### 1. The Incus strategy cannot do multi-container today (relates to #11) Every generic strategy derives its target from `requesting_process` and acts in that process's namespaces, so multiple containers work by construction. `impl InjectionStrategy for Incus` does the opposite: it marks the parameter `_requesting_process` as unused and takes the container name from `get_scope()`, bailing out on `Scope::Multi`. So Incus effectively requires one vuinputd instance per container, and a setup like mine cannot be served by a single daemon. The container name is derivable from the requesting process. On a live two-seat host here, reading the caller's cgroup gives it directly: ``` $ cat /proc/52144/cgroup 0::/lxc.payload.seat1/user.slice/user-1000.slice/user@1000.service/app... $ cat /proc/71439/cgroup 0::/lxc.payload.seat2/user.slice/user-1000.slice/user@1000.service/app... ``` That is Incus 7.2 on a CachyOS host, where the prefix is `lxc.payload.` because Incus still uses LXC underneath; other versions may write `incus.payload.`, so it would want matching on both. Either way it would let the Incus strategy resolve the target the same way the generic ones do and drop the `Scope::Single` restriction. While reading that function I also noticed `mknod_device_node` builds `hostpath` and `incuspath` as the same `path=...` string and passes both, never setting `source=`. Incus tolerates it because `path` doubles as the host path when `source` is absent, so this is cosmetic rather than broken, but it reads like one of the two was meant to be `source=`. ### 2. What I measured about uhid Sunshine creates gamepads through inputtino as HID devices via `/dev/uhid`, not through uinput, and it does so for the **default emulated model** as well, not only for a DualSense. So you do not need to buy a controller to reproduce it: any Moonlight client with an on-screen controller overlay is enough. Without uhid passed through, Sunshine still logs `Gamepad 0 will be Xbox One controller` while no device ever appears inside the container, which is what made it look like a client-side problem to me for quite a while. A uhid device also produces a `hidraw` node alongside the input event node, and Steam Input uses hidraw. So whatever handles uhid has to inject hidraw too. That is an open gap on my side as well. On the protocol: uinput is essentially write-only from the application, while uhid is bidirectional and stateful. The application writes `UHID_CREATE2` and `UHID_INPUT2` but has to read `UHID_START`, `UHID_OPEN`, `UHID_OUTPUT` and `UHID_GET_REPORT` back. Having seen `vuinput_read.rs` and `vuinput_poll.rs`, the relaying shape clearly already exists, so my question is not whether it fits but how you would want it cut: a second CUSE device for `/dev/uhid` next to the existing one, or something more shared? Worth noting that this overlaps with #10: `UHID_OUTPUT` is how rumble arrives for HID devices, so uhid support and the vibration work are partly the same problem. ### 3. What I can actually offer A real two-seat Incus rig with Sunshine, gamepads and the habit of writing down what each experiment measured. I can test branches against it, and I can reproduce #11, #10 and #7. #7 in particular looks familiar: Sunshine's "Mouse passthrough (absolute)" reports `EV_ABS` rather than `EV_REL`, and classifying it wrongly made sway treat it as a keyboard in my setup, so the pointer stayed dead without an error appearing anywhere. What I am not offering is a promise to land uhid support on my own in a codebase I have read but never written in. If you want it from outside, tell me how you would want it cut and I will start with a narrow piece. If you would rather build it yourself, testing and diagnosis are probably where I am more useful anyway.
Author
Owner

@superuser404notfound commented on GitHub (Jul 28, 2026):

Correction to what I wrote above about uhid. I had two seats streaming with a
controller each today and measured it properly, and my earlier claim was wrong.

Which interface a gamepad uses depends on the emulated model, not on Sunshine
as such:

Sunshine X-Box One (virtual) pad   ->  uinput
   /sys/devices/virtual/input/input232

Sunshine PS5 (virtual) pad         ->  uhid
   /sys/devices/virtual/misc/uhid/0005:054C:0CE6.001C/input/input229

The log names the choice and where it came from:

seat1: Gamepad 0 will be Xbox One controller (default)
seat2: Gamepad 0 will be DualShock 5 controller (auto-selected by client-reported type)

So I was wrong to say the default model goes through uhid too. It does not. Only
DualSense emulation does, plus whatever else Sunshine implements as a real HID
descriptor.

My conclusion still holds, but for a different reason. You do not need to buy
a controller to exercise uhid: Sunshine has a gamepad option and the log
distinguishes "manual selection" from "auto-selected by client-reported type", so
setting it to force DS5 emulation should reach the uhid path from any client.

One observation I cannot fully explain, which may matter for your side. With
/dev/uhid not passed into the container, no gamepad appeared at all, while
the log still announced an Xbox One pad, and that is the model we now know uses
uinput. So uhid seems to be required for gamepad support to come up at all, even
when the pad that gets created does not end up using it. If that holds, a
container with a mediated /dev/uinput but no uhid would silently have no
gamepads regardless of the emulated model.

And a detail for whoever implements the uhid side: one DualSense produces
three input devices from a single HID device, the pad itself plus "Motion
Sensors" and "Touchpad". So the injection has to handle a one-to-many mapping,
not one node per creation.

<!-- gh-comment-id:5106356468 --> @superuser404notfound commented on GitHub (Jul 28, 2026): Correction to what I wrote above about uhid. I had two seats streaming with a controller each today and measured it properly, and my earlier claim was wrong. **Which interface a gamepad uses depends on the emulated model, not on Sunshine as such:** ``` Sunshine X-Box One (virtual) pad -> uinput /sys/devices/virtual/input/input232 Sunshine PS5 (virtual) pad -> uhid /sys/devices/virtual/misc/uhid/0005:054C:0CE6.001C/input/input229 ``` The log names the choice and where it came from: ``` seat1: Gamepad 0 will be Xbox One controller (default) seat2: Gamepad 0 will be DualShock 5 controller (auto-selected by client-reported type) ``` So I was wrong to say the default model goes through uhid too. It does not. Only DualSense emulation does, plus whatever else Sunshine implements as a real HID descriptor. **My conclusion still holds, but for a different reason.** You do not need to buy a controller to exercise uhid: Sunshine has a `gamepad` option and the log distinguishes "manual selection" from "auto-selected by client-reported type", so setting it to force DS5 emulation should reach the uhid path from any client. **One observation I cannot fully explain**, which may matter for your side. With `/dev/uhid` not passed into the container, *no* gamepad appeared at all, while the log still announced an Xbox One pad, and that is the model we now know uses uinput. So uhid seems to be required for gamepad support to come up at all, even when the pad that gets created does not end up using it. If that holds, a container with a mediated `/dev/uinput` but no uhid would silently have no gamepads regardless of the emulated model. **And a detail for whoever implements the uhid side:** one DualSense produces three input devices from a single HID device, the pad itself plus "Motion Sensors" and "Touchpad". So the injection has to handle a one-to-many mapping, not one node per creation.
Sign in to join this conversation.
No labels
pull-request
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
joleuger/vuinputd#15
No description provided.