The Problem

Part 2 ended with a gamescope TV session that could be started and stopped independently - but with an immediate new problem: moving the desk mouse moved the cursor in Steam Big Picture on the TV. Keyboard keys navigated Steam menus.

This shouldn’t happen. The desk mouse and keyboard are seat0 devices - they are not tagged for seat1 in udev, so seatd should not have given gamescope access to them. The gamescope log confirmed this - there were no “Adding device” messages from libinput. gamescope itself received no input devices through seatd.

Steam was getting input through a different path.

Investigation

Ruling Out the Wayland Path

The user service inherits WAYLAND_DISPLAY=wayland-1 from the Sway session environment, pointing at Sway’s compositor socket. If Steam connected to Sway as a Wayland client, it would receive pointer and keyboard events through Sway’s seat. Adding UnsetEnvironment=WAYLAND_DISPLAY to the service had no effect. Neither did forcing SDL_VIDEODRIVER=wayland. The input was not coming from Sway via Wayland.

The /dev/input Path

The actual mechanism: logind sets a filesystem ACL on every /dev/input/event* node for the active session user on seat0. Any process running as that user can open these files directly, bypassing seatd entirely. Steam does exactly this.

Two approaches to blocking it:

DeviceDeny=char-input in a systemd unit uses eBPF to block device opens at the cgroup level. In a user service, the user’s cgroup slice does not receive delegation of the device controller from the system manager - the eBPF program is never attached, and the directive is silently ignored.

TemporaryFileSystem=/dev/input mounts an empty tmpfs over /dev/input in a private mount namespace, hiding all device files from the process tree. In a user service, this implicitly enables PrivateUsers=yes to create the namespace without root privileges. The user namespace remaps uid 0 to uid 65534. /tmp/.X11-unix is owned by root, and gamescope’s Xwayland checks for exactly that ownership - the check fails, Xwayland cannot create its socket, and gamescope exits.

Adding PrivateTmp=yes gives gamescope its own /tmp where it creates /tmp/.X11-unix itself (owned by uid 1000, passing the check). But the same user namespace breaks DRM master acquisition through seatd, producing drmModeAtomicCommit: Permission denied.

Moving to a System Service

A system service with User=oleksandr sidesteps the user namespace problem. systemd sets up the mount namespace using its own root capabilities, so uid 0 stays uid 0 inside it. XDG_RUNTIME_DIR and DBUS_SESSION_BUS_ADDRESS are normally provided by the session manager; as a system service, gamescope would not find them without explicit values:

# /etc/systemd/system/gamescope-tv.service
[Unit]
Description=Gamescope TV Session
After=seatd.service

[Service]
Type=simple
User=oleksandr
Environment=XDG_RUNTIME_DIR=/run/user/1000
Environment=XDG_SEAT=seat1
Environment=WLR_DRM_DEVICES=/dev/dri/card1
Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus
TemporaryFileSystem=/dev/input
ExecStart=/usr/local/bin/gamescope-steam-session
Restart=on-failure
RestartSec=3

[Install]
WantedBy=graphical.target

WLR_LIBINPUT_NO_DEVICES=1 is not needed here - with seatd as the backend, gamescope can open input devices (seatd grants them), so libinput initializes without errors. This is, in fact, part of the problem that surfaces next.

Confirming from inside the service’s mount namespace:

sudo nsenter -m -t $(systemctl show gamescope-tv --property MainPID --value) ls /dev/input/
# (empty)

/dev/input was empty. The TV kept showing the gamescope picture. The desk mouse cursor kept moving on the TV.

The /dev/hidraw Path

USB HID devices appear under two interfaces: /dev/input/event* (the evdev interface, consumed by libinput) and /dev/hidraw* (raw HID, used by Steam Input for advanced controller features). The desk has:

/dev/hidraw2-6:  keyboard
/dev/hidraw12:   wireless receiver
/dev/hidraw13:   mouse
/dev/hidraw14:   mouse

Steam reads keyboards and mice through hidraw as well as controllers. With /dev/input blocked, hidraw was the remaining input path.

DeviceDeny=char-hidraw in the system service should block this via eBPF. Applied via systemctl edit, but systemctl show gamescope-tv | grep DevicePolicy returned DevicePolicy=auto - the override was not being picked up and the directive had no effect. Keyboard input continued reaching Steam.

What the Diagnostics Revealed

With /dev/input confirmed empty inside the namespace, the assumption was that gamescope could not open input devices. Checking open file descriptors across every process in the service cgroup said otherwise:

for pid in $(cat /sys/fs/cgroup/system.slice/gamescope-tv.service/cgroup.procs); do
  comm=$(cat /proc/$pid/comm 2>/dev/null)
  fds=$(ls -la /proc/$pid/fd 2>/dev/null | grep -E "hidraw|/dev/input")
  [ -n "$fds" ] && echo "--- $pid ($comm) ---" && echo "$fds"
done
--- 178454 (gamescope-wl) ---
lrwx------ 1 oleksandr oleksandr 64 ... 43 -> /dev/input/event3
lrwx------ 1 oleksandr oleksandr 64 ... 44 -> /dev/input/event4
...
lrwx------ 1 oleksandr oleksandr 64 ... 54 -> /dev/input/event20

gamescope-wl had 14 open file descriptors pointing at /dev/input/event* - every keyboard, mouse, and input device on the desk. Despite the namespace having an empty /dev/input.

The Real Problem: seatd Opens Devices Outside the Namespace

TemporaryFileSystem=/dev/input prevents the service from opening input devices via the /dev/input path. But gamescope does not open input devices itself.

The chain: gamescope - wlroots - libinput - libseat - seatd. libinput enumerates input devices via udev, querying the kernel’s sysfs (not the /dev/input filesystem). It then asks libseat to open each device. libseat’s seatd backend sends an open_device request over the seatd socket. seatd opens the file from its own process, which has no mount namespace restrictions and sees the full /dev/input. It then passes the resulting file descriptor back to gamescope via SCM_RIGHTS over the socket.

gamescope receives an already-open file descriptor. Neither the mount namespace nor the eBPF cgroup device filter intercept this - both only trigger on open() syscalls within the cgroup. A socket-passed fd bypasses both entirely.

TemporaryFileSystem=/dev/input and DeviceDeny were attacking the wrong layer.

The Fix: LIBSEAT_BACKEND=noop

libseat has a noop backend. Its open_device is a direct open() call, no socket, no seatd:

static int open_device(struct libseat *base, const char *path, int *fd) {
    int tmpfd = open(path, O_RDWR | O_NOCTTY | O_NOFOLLOW | O_CLOEXEC | O_NONBLOCK);
    if (tmpfd < 0) {
        return -1;
    }
    *fd = tmpfd;
    return tmpfd;
}

With LIBSEAT_BACKEND=noop, every device open happens inside the service’s own mount namespace. /dev/input is empty there. libinput enumerates the desk input devices via udev, requests each one, gets ENOENT for all of them, and fails to initialize. WLR_LIBINPUT_NO_DEVICES=1 tells wlroots to continue without a libinput backend rather than exit with an error.

For DRM, noop’s direct open() of /dev/dri/card1 succeeds provided the user is in the video group, which grants read-write access to DRM card nodes. With no current DRM master on card1 (Sway holds card2, the iGPU), wlroots’ drmSetMaster() completes without CAP_SYS_ADMIN. WLR_DRM_DEVICES is no longer needed - gamescope claims card1 naturally since it is the only free DRM device.

With seatd out of the picture, the original user service problem disappears. The system service was needed because TemporaryFileSystem=/dev/input in a user service creates a user namespace - and seatd’s fd-passing broke inside that namespace. With noop, there is no seatd, no fd-passing, and no conflict. The final service is the user service this was always meant to be:

# ~/.config/systemd/user/gamescope-tv.service
[Unit]
Description=Gamescope TV Session

[Service]
Type=simple
Environment=LIBSEAT_BACKEND=noop
Environment=WLR_LIBINPUT_NO_DEVICES=1
TemporaryFileSystem=/dev/input
TimeoutStopSec=10s
ExecStart=%h/.local/bin/gamescope-steam-session
Restart=on-failure
RestartSec=3

[Install]
WantedBy=default.target

XDG_SEAT=seat1, DBUS_SESSION_BUS_ADDRESS, WLR_DRM_DEVICES, User=oleksandr, XDG_RUNTIME_DIR, and the DeviceAllow override all dropped - none were needed once seatd was out of the picture.

The libvirt hooks from Part 2 remain unchanged - the service is still a user unit, so systemctl --user -M oleksandr@ still applies.

Rough Edges

Why the DualSense Still Works

The DualSense appears under two interfaces: /dev/input/eventX (evdev, consumed by libinput) and /dev/hidraw* (raw HID). Steam Input uses the hidraw interface directly for full-feature support: adaptive triggers, haptics, gyro, touchpad.

TemporaryFileSystem=/dev/input hides only /dev/input. /dev/hidraw* is untouched. Steam opens the DualSense hidraw node directly and handles all gamepad input through its own HID stack.

Checking open fds in the service cgroup while the controller is connected:

CGROUP=$(systemctl --user show gamescope-tv --property ControlGroup --value)
for pid in $(cat "/sys/fs/cgroup${CGROUP}/cgroup.procs"); do
  comm=$(cat /proc/$pid/comm 2>/dev/null)
  fds=$(ls -la /proc/$pid/fd 2>/dev/null | grep hidraw)
  [ -n "$fds" ] && echo "--- $pid ($comm) ---" && echo "$fds"
done
--- 204891 (steam) ---
lrwx------ 1 oleksandr oleksandr 64 ... 156 -> /dev/hidraw15

One hidraw node, held by steam. Confirming what it is:

cat /sys/class/hidraw/hidraw15/device/uevent
DRIVER=playstation
HID_ID=0005:0000054C:00000CE6
HID_NAME=DualSense Wireless Controller
HID_PHYS=xx:xx:xx:xx:xx:xx
HID_UNIQ=xx:xx:xx:xx:xx:xx
MODALIAS=hid:b0005g0000v0000054Cp00000CE6

VID 054C is Sony, PID 0CE6 is DualSense. The desk mouse and keyboard have no hidraw nodes open in the service at all.

One edge case: if a second user becomes the active session on the same seat, logind revokes the gaming user’s hidraw ACL and Steam loses access to the controller. The fix is a udev rule that grants GROUP="input" static permissions, bypassing the session-scoped ACL entirely. See DualSense Access Lost When Switching Linux Users.

The Log Noise

With the setup working correctly, one nuisance remained: journalctl --user -u gamescope-tv was flooded with a continuous stream of:

gamescope-steam-session[...]: [gamescope] [Error] liftoff: drmModeAtomicCommit: Permission denied

The cause: applications on seat0 using GPU hardware acceleration open /dev/dri/card1 directly (not just the render node /dev/dri/renderD128). Opening the card node makes a process a DRM client. With multiple DRM clients on card1, gamescope occasionally logs this error. It recovers immediately and the TV picture is unaffected - pure log noise.

The obvious fix is LogFilterPatterns= in the service unit. Two things prevent it from working here.

First, gamescope colorizes its output. The raw MESSAGE= field in the journal contains ANSI escape sequences around individual words:

[gamescope] [\x1b[0;31mError\x1b[0m] \x1b[0;37mliftoff:\x1b[0m drmModeAtomicCommit: Permission denied

A literal pattern misses the message because of the escape codes between liftoff: and drmModeAtomicCommit.

Second, the systemd man page for LogFilterPatterns= states:

Note that this functionality is currently only available in system services, not in per-user services.

The directive is silently accepted by the user service and does nothing.

Filtering in the session script itself also fails. Redirecting stderr through grep -v via process substitution causes gamescope to detect that fd 2 is a pipe rather than a journal socket. It calls sd_journal_stream_fd() to create a new direct journal connection for its child processes. Steam inherits this new socket, bypassing the grep filter entirely - its output lands in the system journal without the _SYSTEMD_USER_UNIT=gamescope-tv.service tag, invisible to journalctl --user -u gamescope-tv.

Before the pipe was introduced, fd 2 was already the user journal socket set up by systemd. gamescope children inherited it directly, so Steam logs appeared in the user journal alongside gamescope’s own output.

The spam was left in place. journald rate-limits at 10,000 messages per 30 seconds and caps the journal at roughly 10% of the filesystem. The errors are harmless and the journal will not grow without bound.

Slow Stop: winedevice.exe

Stopping the service occasionally stalls for 90 seconds before completing:

● gamescope-tv.service - Gamescope TV Session
     Active: deactivating (final-sigterm) since ...; 21s ago
   CGroup: ...
           ├─ gamescope ...
           ├─ gamescopereaper ...
           └─ "C:\\windows\\system32\\winedevice.exe"

winedevice.exe is part of Wine’s device management layer, started by Proton when a game or Steam component needs Windows driver emulation. When the service is stopped, systemd sends SIGTERM to every process in the service’s cgroup. gamescope and gamescopereaper exit promptly. winedevice.exe ignores SIGTERM - Wine processes do not handle Unix signals the way native Linux processes do. systemd waits for TimeoutStopSec before sending SIGKILL. The default is 90 seconds.

From the outside this looks like the service has frozen, which is inconvenient when stopping the session to start a VM. TimeoutStopSec=10s (shown in the service unit above) shortens the wait. After 10 seconds, systemd sends SIGKILL to anything still alive in the cgroup, winedevice.exe is gone, and the VM can start.

When the TV is Off

Two side effects appear when the TV is powered off.

Audio routing. The dGPU exposes its HDMI outputs as PipeWire audio sinks. When the TV is off, the kernel marks those HDMI profiles as unavailable - HDMI audio negotiation requires a connected, powered-on display. PipeWire automatically moves active streams to the next available sink, which is the desk speakers. The routing reverses automatically when the TV comes back on.

VM resolution. Launching the Windows VM with GPU passthrough while the TV is off means the passed-through GPU has no monitor attached and no EDID to read. Without EDID, the GPU has no way to know what resolutions the display supports, and Windows falls back to a low default - typically 1024x768. The fix is an EDID emulator: a small dongle that plugs into the GPU’s HDMI or DisplayPort output and presents a static EDID regardless of whether a physical display is connected. One is on the shopping list but not yet installed; for now, starting the VM requires the TV to be on first.

The End State

Three posts to get here, but the result is clean. systemctl --user start gamescope-tv brings up Steam Big Picture on the TV. systemctl --user stop gamescope-tv shuts it down in under 10 seconds. The Sway session on seat0 is untouched in either case. GPU passthrough hooks stop and start the TV session around VM startup automatically. Desk input devices stay on the desk. The DualSense works in full-feature mode through Steam Input. The setup survives reboots and runs unsupervised.