Skip to content

fix(capture): isolate input with EVIOCGRAB instead of xinput disable - #33

Merged
lucky-verma merged 5 commits into
mainfrom
fix/evdev-grab-isolation
Jul 31, 2026
Merged

fix(capture): isolate input with EVIOCGRAB instead of xinput disable#33
lucky-verma merged 5 commits into
mainfrom
fix/evdev-grab-isolation

Conversation

@lucky-verma

@lucky-verma lucky-verma commented Jul 31, 2026

Copy link
Copy Markdown
Owner

What

xinput disable left the machine with no mouse and no keyboard whenever the matching
enable never ran. Remote never hands the cursor back, mwb crashes, gets SIGKILLed, the
connection wedges: any of those and nothing in the system restores your input. The only
way out is replugging hardware, which is how I lost an Xorg session on 30 Jul.

Switched to EVIOCGRAB. The grab belongs to the file descriptor, so the kernel drops it
when the process dies by any means. Killing mwb now gives you your mouse back, which it
did not before. Devices are classified by capability bitmask instead of matching
razer|wooting in the name, so laptop touchpads and touchscreens are covered and power
buttons are left alone. xinput is no longer a dependency.

EVIOCGRAB was tried before and reverted in d407e88. That version kept xinput as a
fallback, so the X device churn stayed, the compositor still stalled, and the cursor
warp on return took ~750ms with the grab held across it. With xinput fully gone the
warp measures 3-4ms and grab causes no device churn at all (0 vs 1 device removed).
Warp still happens before release, deliberately. Releasing first is what caused the
rubberband in 22f85ce.

Four more things fell out of testing it:

  • Stop() could hang forever. Devices were opened blocking, so their fds never reached
    Go's poller and Close() did not interrupt a parked read(2). A silent device's
    goroutine never returned. Opened O_NONBLOCK now.
  • The device set was built once at startup. A receiver that re-enumerated on wake was
    never grabbed and leaked motion to the local screen.
  • Grab and release raced each other. A release from a finished switch-back landed after
    the next switch-out's grab, so local input was live while the cursor was on the
    remote. Showed up in the log as count=11 of=12 followed by released count=12.
  • Inbound was rejected whenever the peer connected from anything but the configured IP.
    Dual-stack peers connect from an IPv6 link-local address that no lookup of their IPv4
    will return, so the link flapped: 4 rejections and 5 reconnects in 20 minutes. Now
    refuses globally routable sources and lets local ones attempt the handshake. The
    shared key still does the authenticating.

Also adds mwb update and mwb version. The release pipeline was passing
-X main.version at a variable that did not exist, so every release build was
unversioned.

Tested

make build, go test -race ./... and golangci-lint run all clean. Switched back and
forth on real hardware, count=12 of=12 every time in both directions, and the counts
stayed right through a fast bounce that used to trip the race.

Not tested

  • Windows lock/unlock recovery.
  • Clipboard paste on the Windows side. The send shows up in the log.
  • The replug that crashed Xorg. That needs a USB re-enumeration while grabbed. The
    reason to replug is gone and grab causes no X churn, but the Xorg fault itself is
    untouched and unproven.
  • kill -9 restoring input was proven on a throwaway uinput device, not against the
    real devices.
  • The new inbound accept path. The flapping stopped, but the peer has only connected
    outbound since, so nothing has exercised it outside the unit tests.

Worth a second look

isAllowedPeer widens who may attempt the handshake to any local-network source. That
is a trust boundary change, not a bug fix, and it deserves a real read rather than a
tick.

setxkbmap, localectl, xrandr and loginctl still shell out without a context
timeout. Out of scope here.

Device isolation used `xinput disable`, which is global X11 state whose
lifetime is not tied to the process. Undoing it was procedural, so any path
that skipped `enableXinput` stranded the machine with no mouse and no
keyboard: the remote never handing the cursor back, a crash, a SIGKILL, a
wedged connection. Nothing in the system would restore them, and recovering
meant replugging hardware — which could crash Xorg and drop the session.

Replace it with EVIOCGRAB, where ownership is the file descriptor. The kernel
drops the grab when the fd closes, including on crash, SIGKILL and OOM, so
restoring local input is structural rather than something a cleanup path can
miss. This also removes the X device-hierarchy churn that `xinput` produced,
and with it the ~1-2s compositor stall on return.

An earlier EVIOCGRAB attempt was reverted in d407e88. It kept xinput as a
fallback, so the churn and its compositor stall remained and the cursor warp
on return took ~750ms with the grab held across it. With xinput gone the warp
measures 3-4ms. The synchronous warp-then-release ordering is kept
deliberately; releasing first and warping asynchronously is what caused the
rubberband regression in 22f85ce.

Classify devices by capability bitmask, mirroring udev's input_id, instead of
matching `razer|wooting` in the name. Laptop touchpads, touchscreens and
tablets are now isolated; power and sleep buttons, lid switches and audio-jack
detection are deliberately left alone so the physical power button stays
available as a recovery path.

Also fixes three defects found while verifying this:

- Stop() could hang forever. Devices were opened blocking, so their fds were
  not registered with Go's poller and Close() did not interrupt a parked
  read(2); a monitor goroutine for a silent device never returned. Open with
  O_NONBLOCK.
- The tracked device set was captured once at startup, so a wireless receiver
  re-enumerating on wake was never isolated and leaked motion to the local
  display. grabInput refreshes the set first, and vanished devices are dropped.
- Never grab virtual (uinput) devices. Grabbing mwb's own mwb-mouse and
  mwb-keyboard would swallow the events it injects, silently breaking remote
  typing and clicking. Other tools' output devices are skipped for the same
  reason.

xinput is no longer a runtime dependency; drop it from the deb, the goreleaser
package and the installer.
`mwb update` fetches the release artifact for the running architecture,
verifies it against the SHA-256 published in that release's checksums.txt, and
replaces the binary atomically, so an interrupted update leaves either the old
binary or the new one and never a truncated file. It refuses unverified
downloads, non-HTTPS URLs and any host that is not GitHub.

Installs from the .deb are left to dpkg rather than overwritten behind the
package manager's back, and an unwritable destination reports the sudo
requirement instead of a bare permission error. The running service keeps the
old binary until restarted, so the restart command is printed.

`mwb version` prints the installed version. The release pipeline already
passed -X main.version, but no such variable existed, so the ldflag was
silently doing nothing and every release build was unversioned.
…he caller

Outbound switches run on pollCursorEdge; returns run on the network handler via
SetActive. With each caller stating its own intent, the release belonging to a
finished switch-back could land after the grab for the next switch-out and
un-grab devices while the cursor was on the remote machine, leaving local input
live — a window of dual-cursor movement.

Observed live:

    44.372  remote edge hit — switching back to Ubuntu
    44.607  screen edge hit, switching to remote
    44.608  WARN grab input device event20 "device or resource busy"
    44.613  grabbed local input devices count=11 of=12
    44.629  released local input devices count=12 of=12   <- stale

applyIsolation now serializes on grabMu and re-reads c.active inside it, so a
late caller converges on the true state instead of overriding a newer one.

setIsolation also skips devices already in the target state. Re-grabbing a
device this process already holds returns EBUSY, which was logged as a failure
and produced the misleading count=11 of=12 above.

Adds test seams for the grab ioctl and device discovery so the state machine is
covered without taking a real user's input away or depending on the test
machine's hardware.
The inbound allowlist matched the source address against the single address
resolved from `host`. That is structurally unsatisfiable for a real peer: the
configured peer is a machine, not an address, and a machine's other addresses
cannot be derived from the one that was configured.

A dual-stack Windows host opens the connection from an IPv6 link-local address
that no lookup of its IPv4 will ever return, so every inbound attempt was
refused and the link flapped between failed accepts and outbound retries.
Measured on a normal session: 4 rejections, 5 resets and 5 reconnect cycles in
20 minutes. DHCP renewal, a second NIC, Wi-Fi/Ethernet failover and VPN routes
have the same effect.

Refuse globally routable sources before they can consume handshake work, so the
internet still cannot reach the handshake, and let local-network sources
(RFC1918/ULA private space, IPv6 link-local) attempt it. Loopback stays refused
because the peer is another machine, and an empty allowlist still fails closed
when resolution failed.

The shared key remains the authentication control. This widens who may attempt
authentication, never who passes it.

Also drops grabTargetFiles, left unused by the applyIsolation refactor.
dpkg-query and systemctl are invoked to describe how mwb was installed. Neither
had a deadline, so a wedged package database or a stalled systemd user bus
could hang `mwb update` indefinitely with no output. Both now run under a 5s
context, matching the repo rule that every exec.Command carries one.
@lucky-verma
lucky-verma merged commit 0fe5933 into main Jul 31, 2026
3 checks passed
@lucky-verma
lucky-verma deleted the fix/evdev-grab-isolation branch July 31, 2026 01:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant