From 9c2fccf0a0f4ef2252df350d057b4e00f75304b7 Mon Sep 17 00:00:00 2001 From: 0x1f6 <178943044+0x1f6@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:05:08 +0200 Subject: [PATCH] Survive a malformed control datagram One bad datagram on the control socket ended the whole relay. Its dispatch in handle_control ran unguarded in the main loop, so any exception propagated out of main() and the process exited. The simplest trigger is "watch abc": float(words[1]) raises ValueError, and the relay died with ValueError: could not convert string to float: 'abc' at handle_control, via the bare handle_control(now) call in the select loop The control socket is an abstract socket bound to @frametop_relay. Abstract sockets carry no permissions, so every local process can send to it; nothing authenticates the sender. Dying from a datagram was bad in three ways: - Every grab is lost. Physical devices go back to gamescope and SteamVR, which read them themselves, so the mouse types into Steam and the desktop at once, and Meta+Shift shortcuts fire on both sides. - The volume-key takeover is lost. gamescope aborts on a volume key when no window has keyboard focus (wlr_seat_keyboard_notify_enter asserts on a null focus surface), which ends the whole VR session - the exact failure the relay exists to prevent. - systemd restarts the service, but Type=notify with READY only after the virtual devices exist makes the restart a visible hiccup, and a crash loop from repeated bad datagrams would flap SteamVR's input. Reproduced by running the relay with stubbed uinput devices on a non-Linux host and sending "watch abc" to the control socket: it exited on the first datagram after answering "devices" correctly. After the fix, the same exchange gets a log line ("bad control datagram ...") and the relay keeps answering. The fix wraps each datagram's dispatch in try/except inside handle_control's loop, so one malformed message is logged and skipped while the rest of the queue is still processed. Parsing of the common helper commands (vrbtn, vrhello, gazeawake, keyboard) keeps its own guards; the catch-all is only a backstop for anything the guards miss, including float() on a non-numeric argument to "watch" and "vrcapture". Adapted for experimental (from PR #8): the guard also wraps the textfield command, which experimental added to this dispatch after the PR's base. (cherry picked from commit ca7e58069b7e4b0fe8ff23a230a641b6bc915f6b) Co-Authored-By: Claude Opus 5.5 --- input/input-relay.py | 90 +++++++++++++++++++++++--------------------- 1 file changed, 48 insertions(+), 42 deletions(-) diff --git a/input/input-relay.py b/input/input-relay.py index 7585f72..fb0a491 100755 --- a/input/input-relay.py +++ b/input/input-relay.py @@ -910,48 +910,54 @@ def main(): return words = data.decode(errors="replace").split() cmd = words[0] if words else "" - if cmd == "keyboard": - # From ft-screens (unbound, no reply): where typing goes, repeated every second. - desktop = len(words) > 1 and words[1] == "desktop" - state["desktop_until"] = now + 3.0 if desktop else 0.0 - continue - if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"): - vr_button(words[1], int(words[2]), now) - continue - if cmd == "vrhello": - vr_bind(now) - continue - if cmd == "textfield" and len(words) == 2: - text_field(words[1] == "1") - continue - if cmd == "gazeawake" and len(words) == 2: - if state["pointer"]: - state["pointer"].gaze_awake_until = now + 12.0 if words[1] == "1" else 0.0 - continue - if not addr: - continue # unbound sender, nowhere to reply - if cmd == "devices": - reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None, - "actions": ACTIONS, - "nodes": [n.describe() for n in nodes.values() if n.candidate]}) - elif cmd == "watch": - seconds = float(words[1]) if len(words) > 1 else 30 - watchers[addr] = now + min(seconds, 600) - reply(addr, {"t": "watching", "seconds": seconds}) - elif cmd == "reload": - load_config() - apply_roles() - if state["pointer"]: - state["pointer"].send("reload") - vr_bind(now) - reply(addr, {"t": "reloaded"}) - elif cmd == "vrcapture": - seconds = float(words[1]) if len(words) > 1 else 30 - state["vr_capture_until"] = now + min(seconds, 120) if seconds > 0 else 0.0 - vr_bind(now) - reply(addr, {"t": "vrcapture", "seconds": seconds}) - else: - reply(addr, {"t": "error", "error": f"unknown command {cmd!r}"}) + # One malformed datagram must not end the relay: it would drop every grab, + # including the volume keys that keep gamescope from aborting. The control + # socket is an abstract socket, so any local process can send to it. + try: + if cmd == "keyboard": + # From ft-screens (unbound, no reply): where typing goes, repeated every second. + desktop = len(words) > 1 and words[1] == "desktop" + state["desktop_until"] = now + 3.0 if desktop else 0.0 + continue + if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"): + vr_button(words[1], int(words[2]), now) + continue + if cmd == "vrhello": + vr_bind(now) + continue + if cmd == "textfield" and len(words) == 2: + text_field(words[1] == "1") + continue + if cmd == "gazeawake" and len(words) == 2: + if state["pointer"]: + state["pointer"].gaze_awake_until = now + 12.0 if words[1] == "1" else 0.0 + continue + if not addr: + continue # unbound sender, nowhere to reply + if cmd == "devices": + reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None, + "actions": ACTIONS, + "nodes": [n.describe() for n in nodes.values() if n.candidate]}) + elif cmd == "watch": + seconds = float(words[1]) if len(words) > 1 else 30 + watchers[addr] = now + min(seconds, 600) + reply(addr, {"t": "watching", "seconds": seconds}) + elif cmd == "reload": + load_config() + apply_roles() + if state["pointer"]: + state["pointer"].send("reload") + vr_bind(now) + reply(addr, {"t": "reloaded"}) + elif cmd == "vrcapture": + seconds = float(words[1]) if len(words) > 1 else 30 + state["vr_capture_until"] = now + min(seconds, 120) if seconds > 0 else 0.0 + vr_bind(now) + reply(addr, {"t": "vrcapture", "seconds": seconds}) + else: + reply(addr, {"t": "error", "error": f"unknown command {cmd!r}"}) + except Exception as e: + log(f"bad control datagram {data!r}: {e!r}") def broadcast(node, etype, code, value, now): if not watchers or not node.candidate: