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 ca7e58069b)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
0x1f6andClaude Opus 5.5 authored and DeeJanuz committed 2026-10-01 09:34:12 -06:00
1 parent d54e907d37
commit 9c2fccf0a0
1 file changed
+48 -42
+48 -42
View File
@@ -910,48 +910,54 @@ def main():
return return
words = data.decode(errors="replace").split() words = data.decode(errors="replace").split()
cmd = words[0] if words else "" cmd = words[0] if words else ""
if cmd == "keyboard": # One malformed datagram must not end the relay: it would drop every grab,
# From ft-screens (unbound, no reply): where typing goes, repeated every second. # including the volume keys that keep gamescope from aborting. The control
desktop = len(words) > 1 and words[1] == "desktop" # socket is an abstract socket, so any local process can send to it.
state["desktop_until"] = now + 3.0 if desktop else 0.0 try:
continue if cmd == "keyboard":
if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"): # From ft-screens (unbound, no reply): where typing goes, repeated every second.
vr_button(words[1], int(words[2]), now) desktop = len(words) > 1 and words[1] == "desktop"
continue state["desktop_until"] = now + 3.0 if desktop else 0.0
if cmd == "vrhello": continue
vr_bind(now) if cmd == "vrbtn" and len(words) == 3 and words[1] in VR_BUTTONS and words[2] in ("0", "1"):
continue vr_button(words[1], int(words[2]), now)
if cmd == "textfield" and len(words) == 2: continue
text_field(words[1] == "1") if cmd == "vrhello":
continue vr_bind(now)
if cmd == "gazeawake" and len(words) == 2: continue
if state["pointer"]: if cmd == "textfield" and len(words) == 2:
state["pointer"].gaze_awake_until = now + 12.0 if words[1] == "1" else 0.0 text_field(words[1] == "1")
continue continue
if not addr: if cmd == "gazeawake" and len(words) == 2:
continue # unbound sender, nowhere to reply if state["pointer"]:
if cmd == "devices": state["pointer"].gaze_awake_until = now + 12.0 if words[1] == "1" else 0.0
reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None, continue
"actions": ACTIONS, if not addr:
"nodes": [n.describe() for n in nodes.values() if n.candidate]}) continue # unbound sender, nowhere to reply
elif cmd == "watch": if cmd == "devices":
seconds = float(words[1]) if len(words) > 1 else 30 reply(addr, {"t": "devices", "pointer_mode": state["pointer"] is not None,
watchers[addr] = now + min(seconds, 600) "actions": ACTIONS,
reply(addr, {"t": "watching", "seconds": seconds}) "nodes": [n.describe() for n in nodes.values() if n.candidate]})
elif cmd == "reload": elif cmd == "watch":
load_config() seconds = float(words[1]) if len(words) > 1 else 30
apply_roles() watchers[addr] = now + min(seconds, 600)
if state["pointer"]: reply(addr, {"t": "watching", "seconds": seconds})
state["pointer"].send("reload") elif cmd == "reload":
vr_bind(now) load_config()
reply(addr, {"t": "reloaded"}) apply_roles()
elif cmd == "vrcapture": if state["pointer"]:
seconds = float(words[1]) if len(words) > 1 else 30 state["pointer"].send("reload")
state["vr_capture_until"] = now + min(seconds, 120) if seconds > 0 else 0.0 vr_bind(now)
vr_bind(now) reply(addr, {"t": "reloaded"})
reply(addr, {"t": "vrcapture", "seconds": seconds}) elif cmd == "vrcapture":
else: seconds = float(words[1]) if len(words) > 1 else 30
reply(addr, {"t": "error", "error": f"unknown command {cmd!r}"}) 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): def broadcast(node, etype, code, value, now):
if not watchers or not node.candidate: if not watchers or not node.candidate: