fix(kvm): follow seat0 active session across login/logout (VA-107) #111

Merged
David merged 1 commit from fix/VA-107-session-follow into main 2026-07-04 17:00:07 +02:00
Owner

Problem (VA-107)

The root system service resolved seat0's active graphical session once at startup and exported its addressing into the process-global environment (export_env). It never re-ran, so the desktop tab (KVM capture + input) was pinned to whatever session existed when the agent started:

  • Boot before login: nothing to attach to, so the capture path fell to x11rb::connect(None) and spammed x11 connect: $DISPLAY variable not set.
  • User logs in: no effect until a manual systemctl restart.
  • Logout / switch-user: the exported env still pointed at the dead session's runtime dir and socket.

Confirmed on desktop-02: at the "Switch User" greeter the mouse/keyboard still worked (uinput writes to /dev/uinput, session-independent) while the display went blank (capture is session-pinned) - the exact split this fix targets.

Re-running the attach on a timer was not viable: writing global env from a worker thread races the capture threads' env reads (set_var/getenv is a data race; std marks set_var unsafe).

Fix

Replace the global-env coupling with a lock-free, always-current SessionAddr published through vervain_agent_kvm::session_ctx and read by every backend at connect time. A logind-driven supervisor (session_watch) subscribes on the system bus to seat0's ActiveSession PropertiesChanged plus the Manager SessionNew/SessionRemoved signals, debounces the greeter-to-user login burst (200 ms), re-resolves via the existing loginctl discovery, and republishes. The desktop tab now follows the seat's active session across boot, login, user-switch, and logout with no restart. No std::env::set_var is used for session addressing anywhere; the per-user install seeds SessionAddr once from its own session env and needs no supervisor.

Backends address the session explicitly instead of via the ambient environment:

  • X11 capture + XTEST input connect to the resolved display with an auth cookie parsed from the session's Xauthority file (x11conn), replacing x11rb::connect(None).
  • grim / wlr-randr get WAYLAND_DISPLAY / XDG_RUNTIME_DIR via Command::env.
  • The xdg-desktop-portal RemoteDesktop / ScreenCast / Screenshot session opens on an explicit zbus connection to /run/user/<uid>/bus, keyed by session identity, torn down and reopened against the new bus when the active session changes (PipeWire already used the portal-returned fd, so no env there).
  • Clipboard, consent-dialog, and VNC helper spawns inherit the active session's env via a shared session_env::child_env().

Invariant

Every capture/input operation addresses the seat's currently-active session, resolved from live shared state at operation time, never a process-start snapshot; session addressing never flows through mutable process-global env. All governed sites were swept and converted (X11 connects x3, is_wayland_session, grim/wlr-randr x4, Screenshot fallback, portal session, clipboard, consent, VNC); uinput/touch/drm_kms are N/A (kernel devices, no session env).

Checks

just pre-commit (fmt, clippy -D warnings, build, test, all default-feature) and a separate wayland-portal clippy --locked --deny warnings both green.

Verification status

Compiles and unit-tests pass, but the live boot/login/logout cycle is not yet verified on hardware - the only confirmed repro box (desktop-02) is in daily use and cannot be logout-tested freely. The mechanism is testable non-destructively by watching journalctl -fu vervain-agent | grep supervisor while doing a fast-user-switch, which should log an active graphical session changed line on each transition.

Reference-implementation comparison (MeshAgent, RustDesk)

Both battle-tested agents confirm the "follow the active session live and retarget" model, and both use a disposable per-session worker rather than root reaching across:

  • MeshAgent: follows changes via fs.watch('/var/run/utmp') + loginctl confirmation, then fork()+setuid(uid) a fresh X11 capture child (XShm + XTest). Its "Wayland support" is not a Wayland capture backend - it detects Wayland/Xwayland and works around it (disable Wayland in GDM config, or spin up an Xvfb virtual desktop). No portal/PipeWire/uinput.
  • RustDesk: root service polls loginctl (~500 ms) and kill/respawns a per-user in-session --server via sudo -u <user>, resolving DISPLAY/XAUTHORITY/WAYLAND_DISPLAY from the session leader's /proc/<pid>/environ. Full Wayland via xdg-desktop-portal + PipeWire (capture) and portal RemoteDesktop + uinput (input). The GDM-Wayland greeter / switch-user screen is explicitly unsupported.

This PR's detection is more event-driven than either (logind D-Bus vs utmp-watch / polling). The notable architectural divergence: this PR keeps the root process reaching across to the session bus/display, whereas both references run capture/input in a process inside the user session (fork+setuid / sudo -u). Root-reaches-across matches what already worked on this box (the pre-fix portal path worked once the env was exported), but the in-session-worker pattern is more robust for Wayland compositors that reject cross-user clients and is a candidate follow-up if verification shows gaps.

#VA-107

🤖 Generated with Claude Code

## Problem (VA-107) The root system service resolved seat0's active graphical session **once at startup** and exported its addressing into the process-global environment (`export_env`). It never re-ran, so the desktop tab (KVM capture + input) was pinned to whatever session existed when the agent started: - Boot before login: nothing to attach to, so the capture path fell to `x11rb::connect(None)` and spammed `x11 connect: $DISPLAY variable not set`. - User logs in: no effect until a manual `systemctl restart`. - Logout / switch-user: the exported env still pointed at the dead session's runtime dir and socket. Confirmed on desktop-02: at the "Switch User" greeter the mouse/keyboard still worked (uinput writes to `/dev/uinput`, session-independent) while the display went blank (capture is session-pinned) - the exact split this fix targets. Re-running the attach on a timer was not viable: writing global env from a worker thread races the capture threads' env reads (`set_var`/`getenv` is a data race; std marks `set_var` unsafe). ## Fix Replace the global-env coupling with a lock-free, always-current `SessionAddr` published through `vervain_agent_kvm::session_ctx` and read by every backend at connect time. A logind-driven supervisor (`session_watch`) subscribes on the **system** bus to seat0's `ActiveSession` `PropertiesChanged` plus the Manager `SessionNew`/`SessionRemoved` signals, debounces the greeter-to-user login burst (200 ms), re-resolves via the existing `loginctl` discovery, and republishes. The desktop tab now follows the seat's active session across boot, login, user-switch, and logout with no restart. No `std::env::set_var` is used for session addressing anywhere; the per-user install seeds `SessionAddr` once from its own session env and needs no supervisor. Backends address the session explicitly instead of via the ambient environment: - X11 capture + XTEST input connect to the resolved display with an auth cookie parsed from the session's Xauthority file (`x11conn`), replacing `x11rb::connect(None)`. - `grim` / `wlr-randr` get `WAYLAND_DISPLAY` / `XDG_RUNTIME_DIR` via `Command::env`. - The xdg-desktop-portal RemoteDesktop / ScreenCast / Screenshot session opens on an explicit zbus connection to `/run/user/<uid>/bus`, keyed by session identity, torn down and reopened against the new bus when the active session changes (PipeWire already used the portal-returned fd, so no env there). - Clipboard, consent-dialog, and VNC helper spawns inherit the active session's env via a shared `session_env::child_env()`. ## Invariant Every capture/input operation addresses the seat's currently-active session, resolved from live shared state at operation time, never a process-start snapshot; session addressing never flows through mutable process-global env. All governed sites were swept and converted (X11 connects x3, `is_wayland_session`, grim/wlr-randr x4, Screenshot fallback, portal session, clipboard, consent, VNC); `uinput`/`touch`/`drm_kms` are N/A (kernel devices, no session env). ## Checks `just pre-commit` (fmt, clippy `-D warnings`, build, test, all default-feature) and a separate `wayland-portal` clippy `--locked --deny warnings` both green. ## Verification status Compiles and unit-tests pass, but the live boot/login/logout cycle is **not yet verified on hardware** - the only confirmed repro box (desktop-02) is in daily use and cannot be logout-tested freely. The mechanism is testable non-destructively by watching `journalctl -fu vervain-agent | grep supervisor` while doing a fast-user-switch, which should log an `active graphical session changed` line on each transition. ## Reference-implementation comparison (MeshAgent, RustDesk) Both battle-tested agents confirm the "follow the active session live and retarget" model, and both use a **disposable per-session worker** rather than root reaching across: - **MeshAgent**: follows changes via `fs.watch('/var/run/utmp')` + `loginctl` confirmation, then `fork()`+`setuid(uid)` a fresh X11 capture child (XShm + XTest). Its "Wayland support" is not a Wayland capture backend - it detects Wayland/Xwayland and works around it (disable Wayland in GDM config, or spin up an Xvfb virtual desktop). No portal/PipeWire/uinput. - **RustDesk**: root service **polls** `loginctl` (~500 ms) and kill/respawns a per-user in-session `--server` via `sudo -u <user>`, resolving DISPLAY/XAUTHORITY/WAYLAND_DISPLAY from the session leader's `/proc/<pid>/environ`. Full Wayland via xdg-desktop-portal + PipeWire (capture) and portal RemoteDesktop + uinput (input). The GDM-Wayland greeter / switch-user screen is explicitly unsupported. This PR's detection is more event-driven than either (logind D-Bus vs utmp-watch / polling). The notable architectural divergence: this PR keeps the **root process reaching across** to the session bus/display, whereas both references run capture/input in a process **inside the user session** (`fork`+`setuid` / `sudo -u`). Root-reaches-across matches what already worked on this box (the pre-fix portal path worked once the env was exported), but the in-session-worker pattern is more robust for Wayland compositors that reject cross-user clients and is a candidate follow-up if verification shows gaps. #VA-107 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(kvm): follow seat0 active session across login/logout (VA-107)
All checks were successful
Check / fmt + clippy + tests (pull_request) Successful in 8m4s
Create release / Create release from merged PR (pull_request) Has been skipped
590ea12dc2
The root system service resolved the active graphical session once at startup and exported its addressing (XDG_RUNTIME_DIR, DBUS_SESSION_BUS_ADDRESS, WAYLAND_DISPLAY / DISPLAY / XAUTHORITY) into the process-global environment. It never re-ran, so the desktop tab (KVM capture + input) was pinned to whatever session existed when the agent started: booting before login left it spamming "x11 connect: $DISPLAY variable not set", logging in did not help until a manual restart, and logging out left it addressing the dead session's runtime dir and socket. Re-running the attach on a timer was not an option because writing global env from a worker thread races the capture threads' env reads (set_var/getenv is a data race, which is why std marks set_var unsafe).

This replaces the global-env coupling with a lock-free, always-current SessionAddr published through vervain_agent_kvm::session_ctx and read by every backend at connect time. A logind-driven supervisor (session_watch) subscribes on the system bus to seat0's ActiveSession PropertiesChanged plus the Manager SessionNew / SessionRemoved signals, debounces the greeter-to-user login burst, re-resolves via the existing loginctl discovery, and republishes, so the desktop tab follows the seat's active session across boot, login, user-switch, and logout with no restart. No std::env::set_var is used for session addressing anywhere; the per-user install seeds SessionAddr once from its own session env and needs no supervisor.

Backends now address the session explicitly instead of through the ambient environment: X11 capture and XTEST input connect to the resolved display with an auth cookie parsed from the session's Xauthority file (x11conn) rather than x11rb::connect(None); grim and wlr-randr receive WAYLAND_DISPLAY / XDG_RUNTIME_DIR via Command::env; the xdg-desktop-portal RemoteDesktop / ScreenCast / Screenshot session is opened on an explicit zbus connection to /run/user/<uid>/bus, keyed by session identity and torn down and reopened against the new bus when the active session changes; and the clipboard, consent-dialog, and VNC helper spawns inherit the active session's env via a shared session_env::child_env() rather than the agent's own.

#VA-107

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
David scheduled this pull request to auto merge when all checks succeed 2026-07-04 16:54:39 +02:00
David merged commit e2b11f9e7a into main 2026-07-04 17:00:07 +02:00
David deleted branch fix/VA-107-session-follow 2026-07-04 17:00:07 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/vervain-agent!111
No description provided.