From 1bb8e73cb9932b3e7235fd43a45e4fc129123290 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:54:08 -0600 Subject: [PATCH 1/3] frametop-float: a late reply can't start a second poll The watchdog from #35 starts a new NextCommand call when the old one hasn't answered. If the old call's reply still came after that, its callback started another poll, and two polls would answer each other for good: ft-floatd answers a waiting poll empty whenever the next one arrives, so KWin and ft-floatd would trade D-Bus calls in a loop until the script reloads. Each call now has a serial. A reply to a call the watchdog gave up on still runs its commands (ft-floatd sends each one once), but only the current call polls again. With the period at 30 s that reply can only come if KWin stalls with it queued: KWin's callDBus ends every call by its 25 s D-Bus timeout. The period was ft-floatd's POLL_SECONDS plus 10 s, though, so lowering POLL_SECONDS below 15 would have made the loop easy to reach. The period is now a plain 30 s, and the comment says it has to stay above KWin's timeout. In a model of the poll (the script's code run in Qt's JS engine against ft-floatd's wait() and QtDBus's timeout): with the watchdog at 15 s and ft-floatd blocked for 15 s, the old code looped (5000 calls in 10 s), and this one makes 2. The same with KWin stalled and the timer run before the queued reply. In normal use nothing changes: the same calls, no fires. Co-Authored-By: Claude Opus 5.5 --- float/frametop-float.js | 27 ++++++++++++++++++--------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/float/frametop-float.js b/float/frametop-float.js index 4d6af86..a4a7fca 100644 --- a/float/frametop-float.js +++ b/float/frametop-float.js @@ -411,27 +411,36 @@ function run(c) { } } -// The long poll has one failure mode: a reply that never comes (ft-floatd dying mid-call, -// a D-Bus hiccup) leaves polling true forever and the script stops hearing commands until -// KWin reloads it. A watchdog re-arms the poll if the reply is late by more than -// ft-floatd's own timeout (POLL_SECONDS in ft_floatd.py) plus slack. -const POLL_SECONDS = 20; +// The long poll has one failure mode: a reply that never comes. KWin's callDBus never calls +// back when a call fails (an error reply, ft-floatd gone, or its 25 s D-Bus timeout, which +// ft-floatd blocked that long hits); it only logs "Received D-Bus message is error". Then +// polling stays true and the script stops hearing commands until KWin reloads it. A watchdog +// re-arms the poll. Its period must stay above that 25 s timeout, whatever ft-floatd's +// POLL_SECONDS is: by then the call it gives up on has ended. The serial is for a reply that +// still comes later (KWin stalled with the reply queued): it runs its commands (ft-floatd +// sends each one once) but doesn't poll again. Two polls would answer each other for good, +// since ft-floatd answers a waiting poll empty when the next one comes. const pollWatchdog = new QTimer(); pollWatchdog.singleShot = true; -pollWatchdog.interval = (POLL_SECONDS + 10) * 1000; +pollWatchdog.interval = 30000; pollWatchdog.timeout.connect(() => { print("frametop-float: NextCommand didn't answer, polling again"); polling = false; poll(); }); +let pollSerial = 0; function poll() { if (polling) return; polling = true; + const serial = ++pollSerial; pollWatchdog.start(); callDBus(SERVICE, PATH, IFACE, "NextCommand", reply => { - pollWatchdog.stop(); - polling = false; + const current = serial === pollSerial; // not a call the watchdog gave up on + if (current) { + pollWatchdog.stop(); + polling = false; + } if (reply) { try { JSON.parse(reply).forEach(run); @@ -439,7 +448,7 @@ function poll() { print("frametop-float: bad command " + reply + ": " + e); } } - poll(); + if (current) poll(); }); } From 903abd75dd3c4bee01929c979f6c8b7880041822 Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:54:53 -0600 Subject: [PATCH 2/3] frametop-float: a gone ft-floatd costs one line, and fewer polls While ft-floatd isn't running, the watchdog fired every 30 s for as long as the desktop ran, and each fire was two lines in the journal: the script's own and KWin's "Received D-Bus message is error" for the call that failed. That was about 5800 lines a day, for nothing: only starting ft-floatd again helps, and it reloads the script when it starts. ft-floatd starts from the session's autostart, and nothing restarts it. The script now says it once per outage, and the next reply clears that. Each fire after the first waits twice as long, up to 5 minutes, which also cuts KWin's line. A reply starts over at 30 s, so a lost reply is still polled again after 30 s. In the poll model: ft-floatd gone for 10 minutes went from 20 fires and 20 prints to 4 fires (after 30, 60, 120 and 240 s) and 1 print. A lost reply still recovers at 30 s. Two lost in a row, with no reply between them, now wait 30 and then 60 s. Co-Authored-By: Claude Opus 5.5 --- float/frametop-float.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/float/frametop-float.js b/float/frametop-float.js index a4a7fca..5854075 100644 --- a/float/frametop-float.js +++ b/float/frametop-float.js @@ -420,11 +420,19 @@ function run(c) { // still comes later (KWin stalled with the reply queued): it runs its commands (ft-floatd // sends each one once) but doesn't poll again. Two polls would answer each other for good, // since ft-floatd answers a waiting poll empty when the next one comes. +// When the watchdog fires again and again, ft-floatd is gone, and only starting it again +// helps (it reloads the script then). Each failed call is a line in KWin's log, so the +// script says so once and waits twice as long each time, up to 5 minutes. A reply starts +// over at 30 s, so a lost reply is still polled again after 30 s. +const POLL_WAIT = 30000, POLL_WAIT_MAX = 300000; const pollWatchdog = new QTimer(); pollWatchdog.singleShot = true; -pollWatchdog.interval = 30000; +pollWatchdog.interval = POLL_WAIT; +let pollLost = false; // the watchdog fired, and no reply since pollWatchdog.timeout.connect(() => { - print("frametop-float: NextCommand didn't answer, polling again"); + if (!pollLost) print("frametop-float: NextCommand didn't answer, polling again"); + pollLost = true; + pollWatchdog.interval = Math.min(pollWatchdog.interval * 2, POLL_WAIT_MAX); polling = false; poll(); }); @@ -439,6 +447,8 @@ function poll() { const current = serial === pollSerial; // not a call the watchdog gave up on if (current) { pollWatchdog.stop(); + pollWatchdog.interval = POLL_WAIT; + pollLost = false; polling = false; } if (reply) { From f6b4323669fcea3eb646e731ae31f55bb8056ead Mon Sep 17 00:00:00 2001 From: DeeJanuz <45082401+DeeJanuz@users.noreply.github.com> Date: Mon, 5 Oct 2026 16:55:31 -0600 Subject: [PATCH 3/3] Docs: the float script's poll watchdog, and callDBus dropping failed calls design.md's notes on KWin's script engine get the quirk the watchdog from #35 is for: callDBus never calls back when a call fails (an error reply, the name gone from the bus, or the 25-second timeout), and KWin only logs it. The long poll's description says how the watchdog calls again (after 30 seconds, then longer each time while no reply comes) and why the 30 seconds have to stay above KWin's timeout. floating-windows.md's line on the poll says the script also calls again after 30 seconds without an answer. Co-Authored-By: Claude Opus 5.5 --- docs/design.md | 3 ++- docs/floating-windows.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/design.md b/docs/design.md index b61f77b..1849d05 100644 --- a/docs/design.md +++ b/docs/design.md @@ -85,12 +85,13 @@ In a game, Frametop's panels work like SteamVR's own floating windows: point a c ### The KWin script -The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. A few things about KWin's script engine: +The KWin side is a script (`float/frametop-float.js`), not a C++ effect, because a script keeps working across KWin updates and an effect would have to match the host's exact KWin build. KWin scripts can call D-Bus but can't serve it, so ft-floatd's commands come back through a long poll: the script calls `NextCommand`, which answers when a command is ready, or empty after 20 seconds, under KWin's 25-second D-Bus timeout. If no reply comes within 30 seconds, a watchdog calls again, and waits twice as long each time until a reply comes (up to 5 minutes). The 30 seconds have to stay above KWin's timeout, or a late reply would start a second poll. A few things about KWin's script engine: - `windowAdded` reports popups as windows of their own (`popupWindow` true, `transient` true) with their geometry. - Setting `frameGeometry` applies asynchronously: the app has to answer the new size first. - A script can't read a window's maximize mode, so the script counts a window as maximized when it fills its output's maximize area. - `globalThis` isn't defined. `print` goes to the journal unless `QT_FORCE_STDERR_LOGGING=1`. +- `callDBus` never calls back when a call fails (an error reply, the name gone from the bus, or the 25-second timeout). KWin only logs `Received D-Bus message is error`, so a script that waits for the callback waits forever. The title bar's float button is Frametop's own window decoration (`decoration/`), written in QML for KWin's Aurorae engine, which loads it without compiling. A C++ fork of Breeze would have to match SteamOS's exact KDecoration build. A decoration can only make the window requests KWin offers it, so the button toggles keep-below, which has no visible effect on a window alone on its own output, and the script treats keep-below as the floating flag. diff --git a/docs/floating-windows.md b/docs/floating-windows.md index bdea861..17b3b01 100644 --- a/docs/floating-windows.md +++ b/docs/floating-windows.md @@ -85,7 +85,7 @@ ft-screens creates a `screen` for each toplevel in the order they appear, and in runs commands ◀─ long poll ─ spare outputs (kscreen-doctor) ◀─ @frametop_float ─ lasers, catcher ``` -- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again. It also keeps KWin's placement memory from moving windows (see design.md). +- **KWin script `frametop-float`** (`float/frametop-float.js`). ft-floatd loads it into the desktop's KWin over D-Bus (`org.kde.kwin.Scripting`). A script keeps working across KWin updates. A C++ effect would have to match the host's exact KWin build, and the build container is Fedora, not SteamOS. The script watches windows (`windowAdded`/`windowRemoved`, `frameGeometryChanged`, `outputChanged`, `interactiveMoveResizeStarted`/`Finished`, `fullScreenChanged`, `maximizedChanged`, `minimizedChanged`, `keepBelowChanged`, `windowActivated`) and the outputs (`screensChanged`). It runs commands: move a window to an output, set its geometry, put it on all virtual desktops, and restore it. It adds "Float in VR" ("Back to Desktop" on a floating window) to the window menu (`registerUserActionsMenu`). It registers no shortcut: the float key belongs to the input relay. KWin scripts can call D-Bus but can't serve it, so commands come back through a long poll. The script calls ft-floatd's `NextCommand`, which answers when a command is ready, and then the script calls it again, or after 30 seconds without an answer. It also keeps KWin's placement memory from moving windows (see design.md). - **ft-floatd** (`float/ft-floatd`, Python). The host has dbus-python and PyGObject. It runs inside the desktop's Plasma session, started from its autostart. It owns `org.frametop.Float` on the session's private bus, and it keeps the table of which window is on which output and panel. It enables and disables spare outputs and sets their scale and position with `kscreen-doctor`, and their size through ft-screens. It tells ft-screens where each floating window goes and tells the script which window goes where. It launches apps floating, opens profiles' apps, and remembers each app's placement and scale, keyed by desktop file name. Commands come in on `@frametop_float`, from `ft-float`, the input relay, and ft-screens. - **ft-screens.** A spare output's panel is a floating window's. Floating panels get the same bar, curve, roll, resize tab, and wrist and head pins as screens, plus dock and close buttons left of the bar. Other parts: the catcher, popup and dialog overlays, and carrying a panel during a KWin move. `MAX_SCREENS` (screens and spares together) is 24. Commands arrive on `@ft_screens`. Events go out to `@frametop_float` from an unbound socket, the same way ft-screens talks to the input relay. - **Session script.** Adds `FLOAT_SLOTS` to KWin's output count, starts ft-floatd from the desktop's autostart, installs Frametop's window decoration and chooses it in the session's `kwinrc`, and writes the Launch as Standalone copies of the apps' desktop files.