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 1/2] 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: From 0f4243c7f2225cbb13caf2c147fd0f1ca0858dff Mon Sep 17 00:00:00 2001 From: 0x1f6 <178943044+0x1f6@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:10:09 +0200 Subject: [PATCH 2/2] Plan a layout when the rows setting exceeds what the screens fill ft-layout crashed - and so did arranging the screens - whenever the row setting in the layout preset is above what the screens fill. plan() builds a grid with cols = ceil(count / rows) columns; when that leaves fewer full rows than the setting asked for, the extra rows are empty, and the per-row height max() over an empty row raised ValueError: max() arg is an empty sequence at plan, line 300 (flat) and 312 (curved) The smallest real case is 4 screens with 3 rows: cols = ceil(4/3) = 2, the first two rows hold all 4 screens, the third row is empty. Both the flat wall and the curved preset crashed, so apply, plan and the auto-arrange at desktop start all failed until the row setting was lowered. The Rows spinner in Frametop Display Settings (main.qml) allows any row count up to the screen count, and clamping to the screen count does not prevent this - 3 rows for 4 screens is within that range and never fits a full grid - so the crash was reachable from the UI as shipped. Reproduced by calling plan() directly with 4 screens and 3 rows: both kinds raised. Also verified the whole placement matrix (counts 1-10, rows 1-4) places every screen after the fix. The fix trims rows to the number of rows the screens actually fill, rows = ceil(count / cols), after cols is computed. The row count is used again for stacking (gap times rows - 1, the sum of row heights), so the stacking matches the trimmed grid: no empty row is ever built, and a rows setting that can't be honoured degrades to the tightest fit instead of failing. (cherry picked from commit f2bdbd97a0561a180d91aba034f7848b5a1be24f) Co-Authored-By: Claude Opus 5.5 --- layout/ft_layout.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/layout/ft_layout.py b/layout/ft_layout.py index a7fdd36..21b9900 100755 --- a/layout/ft_layout.py +++ b/layout/ft_layout.py @@ -318,6 +318,10 @@ def plan(layout, count, panel_size=None): p = layout["preset"] rows = max(1, min(int(p.get("rows", 1)), count)) cols = math.ceil(count / rows) + # A row setting above what the screens fill leaves empty grid rows (4 screens, + # 3 rows -> cols 2 -> a third row with nothing in it), and max() over an empty + # row crashes plan(). Trim rows to what the screens actually fill. + rows = max(1, math.ceil(count / cols)) d = max(0.3, float(p.get("distance", 2.0))) gap = max(0.0, float(p.get("gap", 0.05))) height = float(p.get("height", 0.0))