feat(vdisplay): complete Stage 5 §6A group semantics — per-group restore, Mutter group-aware, gamescope groups

Host-side completion of Stage 5 (§6A many-clients-as-monitors), all unit-tested;
two-session on-glass validation still pending (no GPU on the dev VM):

- Per-group topology restore (§6.1): the KWin `exclusive` restore no longer rides
  the per-session StopGuard (which re-enabled the physical the moment the FIRST of
  several exclusive sessions dropped, under a live sibling). KWin hands its restore
  to the registry as a closure (new trait `take_topology_restore`); the registry
  keeps it in the display group (`Entry.topology_restore`) and, on teardown, floats
  it to a surviving same-group sibling (`hand_off_restore`) or runs it when the group
  empties — outside the lock, before the last output's keepalive drops, so the
  compositor never sees zero outputs. All three teardown paths (lease drop / linger
  expiry / mgmt release) honor it. Single-display path byte-for-byte unchanged.
  Unit-tested: float / run-on-last / non-carrier-first / never-cross-backend.

- Mutter group-aware (new trait `set_first_in_group`): the registry tells each
  backend whether it's the first display of its group; a non-first Mutter session
  EXTENDS into the already-exclusive desktop instead of re-applying a sole-monitor
  ApplyMonitorsConfig that would disable the first session's virtual. (Mutter
  connectors are un-nameable, so it can't build a keep-all-virtuals config; skipping
  is the safe equivalent.) Single-session unchanged. Residual APPLY_TEMPORARY revert
  documented.

- gamescope groups (§6.1): `registry::group_key` makes each gamescope spawn its own
  group (independent nested session, no shared desktop) — never auto-rowed against or
  restore-/topology-grouped with another gamescope. Applied in both the /display/state
  assembly and the acquire-time position computation. Unit-tested.

Remaining Stage 5: the web console arrangement table, on-glass validation, and the
documented residuals (wlroots exclusive, Mutter APPLY_TEMPORARY). design doc updated.

cargo build/test (214)/clippy --all-targets/fmt green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-05 13:26:25 +00:00
parent e0f15822ae
commit 87435e6547
5 changed files with 413 additions and 89 deletions
+60 -34
View File
@@ -1,11 +1,15 @@
# Virtual-display management & lifecycle policy — design
> **Status (2026-07-05):** **Stages 04 DONE + on-glass validated; Stage 5 IN PROGRESS** (branch
> `display-mgmt-stage0`, not yet merged). Stage 5 so far: group-aware KWin `exclusive` (§6.1) + the
> **layout foundation** — a pure arrangement engine (`vdisplay/layout.rs`, auto-row + manual), the
> `PUT /display/layout` mgmt endpoint, group/position/index surfaced in `/display/state`, and KWin
> manual-position apply. See the **Status — handoff** block under §11 for the per-stage state, the key
> decisions (notably the Windows `reject` default), and what's left.
> **Status (2026-07-05):** **Stages 04 DONE + on-glass validated; Stage 5 HOST-SIDE DONE** (branch
> `display-mgmt-stage0`, not yet merged). Stage 5 §6A host-side complete: display **groups**
> (`registry::group_key` — one per desktop backend, each gamescope spawn its own), group-aware
> `exclusive`/`primary` (KWin name-filter + first-slot-wins; Mutter `set_first_in_group`), **per-group
> topology restore** (KWin restore floats through the group, runs on the last member's teardown), the
> **layout engine** (`vdisplay/layout.rs`, auto-row + manual) + registry-driven `apply_position`, and the
> `PUT /display/layout` endpoint with group/position/index in `/display/state`. **Remaining Stage 5:** the
> web console arrangement table + on-glass validation (2 clients on a GPU box) + a couple of documented
> residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY` revert). See the **Status — handoff** block
> under §11 for the per-stage state and the key decisions (notably the Windows `reject` default).
> This doc designs a **policy layer on top of the
> existing per-compositor `VirtualDisplay` backends** — user-configurable lifecycle (keep-alive
> after disconnect), topology (primary / exclusive), conflict handling (what happens when a second
@@ -670,17 +674,20 @@ GNOME/Mutter, RTX 5070 Ti), **`.116`** (Bazzite KDE/KWin, AMD — build via a `f
via `effective_topology()`), 3 (platform-neutral `identity.rs` map + `per-client-mode` + KWin per-slot
output naming → **KWin persists per-output scale by name**, proven via `kwinoutputconfig.json` on `.116`),
4 (mode-conflict admission — `vdisplay/admission.rs`, loopback-validated for all four policies).
- **Stage 5: IN PROGRESS.** Landed: (a) the §6.1 **group-aware exclusive** fix for KWin
(`kwin.rs` `MANAGED_PREFIX` + first-slot-wins), unit-tested but NOT yet driven by two concurrent
sessions on-glass; (b) the **layout foundation** — a pure arrangement engine
(`vdisplay/layout.rs::arrange`, auto-row + manual, unit-tested), a group model in the Linux registry
(group = backend; `/display/state` now carries `group`/`display_index`/`position`/`identity_slot`/
`topology`, positions computed via the engine), the `PUT /api/v1/display/layout` endpoint (persists a
manual arrangement via the pure `EffectivePolicy::with_manual_layout` transform), and KWin
**manual-position apply** at create (`apply_manual_position`, guarded + best-effort — a no-op under
auto-row / an unpinned slot, so the default path is untouched). The registry reads the backend's
resolved slot via a new `VirtualDisplay::last_identity_slot` (only KWin reports one), so the
arrangement + state honestly key on per-client identity. Still TODO in Stage 5 (below).
- **Stage 5: HOST-SIDE DONE (web table + on-glass pending).** All §6A group semantics landed + unit-tested
(no two-session on-glass possible on the GPU-less dev VM): **display groups** (`registry::group_key` — one
per desktop backend, each gamescope spawn its own group), **group-aware exclusive/primary** (KWin
`MANAGED_PREFIX` + first-slot-wins; Mutter `set_first_in_group` → a non-first session extends rather than
re-clobbering), **per-group topology restore** (KWin hands its restore to the registry via
`take_topology_restore`; `Entry::topology_restore` + `hand_off_restore` float it to a surviving sibling
and run it only when the group empties, before the last output drops — all 3 teardown paths), the pure
**layout engine** (`vdisplay/layout.rs::arrange`, auto-row + manual) + **registry-driven `apply_position`**
(`position_for_new` over the whole group; skips the origin so the single-display path is unchanged), the
`PUT /api/v1/display/layout` endpoint (`EffectivePolicy::with_manual_layout`), and `/display/state` now
carrying `group`/`display_index`/`position`/`identity_slot`/`topology`. The registry keys the arrangement
on per-client identity via `VirtualDisplay::last_identity_slot` (KWin). **Remaining:** the web arrangement
table + on-glass validation + the documented residuals (wlroots `exclusive`, Mutter `APPLY_TEMPORARY`
revert) — see the Stage 5 entry below.
**Decisions / deltas from this plan as written — read before continuing:**
- **Windows admission default is `reject`, NOT `join`** (supersedes the Stage-4 line below). Two
@@ -734,10 +741,10 @@ Stage-5 group-aware exclusive.
`join`/silent-reconfigure originally planned** — see the handoff Decisions above (single-capturer
IDD-push). Loopback-validated (all four policies) + `.173` reject-default validated; GameStream 503
unit-tested, Moonlight-pending.
- **Stage 5 — §6A multi-client monitors. [IN PROGRESS]** Display groups, group-aware exclusive/primary/
restore (incl. the name-filter fix), layout auto-row + manual, `/display/layout`, console
arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
**Done so far:**
- **Stage 5 — §6A multi-client monitors. [HOST-SIDE DONE ✓ — web table + on-glass pending]** Display
groups, group-aware exclusive/primary/restore (incl. the name-filter fix), layout auto-row + manual,
`/display/layout`, console arrangement table. Cheap: rides Stages 13 infrastructure, no protocol change.
**Done:**
- KWin group-aware `exclusive` (the name-filter fix — recognise the managed group by the
`Virtual-punktfunk` prefix instead of one hardcoded name) + first-slot-wins for the group primary,
unit-tested.
@@ -763,20 +770,39 @@ Stage-5 group-aware exclusive.
first-of-group session (and every non-KWin backend, which no-ops `apply_position`) issues no
positioning at all — the historical single-display path is byte-for-byte unchanged. `position_for_new`
is unit-tested. *On-glass-validation-pending (kscreen positioning of a live virtual output).*
- **Per-group topology restore** (design §6.1 — restore the physical only when the group's LAST member
drops): the KWin `exclusive` restore no longer rides the per-session `StopGuard` (which would re-enable
the physical the moment the FIRST of several exclusive sessions dropped, under a live sibling). KWin
now hands the restore to the registry as a closure (`take_topology_restore`); the registry keeps it in
the display **group** (`Entry::topology_restore`) and, on teardown, **floats** it to a surviving
same-group sibling (`hand_off_restore`) or, when the group empties, runs it — outside the lock, BEFORE
the last output's keepalive drops, so the compositor never sees zero outputs. All three teardown paths
(lease drop / linger expiry / mgmt release) honor it. The single-display path is byte-for-byte
unchanged (one member → run on its teardown). `hand_off_restore` is unit-tested (float / run-on-last /
non-carrier-first / never-cross-backend). *Residual concurrent-connect race + two-session on-glass
validation pending.*
- **Mutter group-aware** (`set_first_in_group`): the registry tells each backend whether it is the
FIRST display of its group; a non-first Mutter session **extends** into the already-exclusive desktop
instead of re-applying a sole-monitor `ApplyMonitorsConfig` that would disable the first session's
virtual. (Simpler than the originally-planned "include all group virtuals," which Mutter can't do —
its connectors are un-nameable — and achieves the same connect-time outcome.) Single-session unchanged
(`first == true`). *Residual: Mutter `APPLY_TEMPORARY` reverts the topology when the FIRST session
leaves under a live sibling (§7) — a full fix needs a group-owned `DisplayConfig` connection; deferred.
Concurrent-Mutter on-glass validation pending (even ≥2 `RecordVirtual` monitors is unproven).*
- **gamescope groups** (design §6.1): a gamescope **spawn** is an independent nested session per client
(no shared desktop), so `registry::group_key` makes each gamescope display its OWN group — never
auto-rowed against, topology-grouped with, or restore-grouped with another gamescope. Unit-tested.
(§6B single-output "decline extras" is Stage 6.)
**TODO (still Stage 5):**
- **Mutter + wlroots group-aware analogues** (Mutter is more involved — its sole-monitor
`ApplyMonitorsConfig` must include ALL group virtuals, not just its own; it can't name-filter like
KWin — the registry must tell it which pre-existing connectors are managed siblings).
- **Per-group topology restore** (restore the physical only when the group's LAST member drops): KWin's
`exclusive` acquire is group-aware, but its RESTORE is still per-display (the `StopGuard` re-enables
the physical on its own teardown), so the first sibling out re-enables the physical while a sibling is
still exclusive. The clean fix moves the restore into a registry group record (run once, when the
group empties, ordered BEFORE the last member's output is reclaimed so KWin never sees zero outputs).
Needs two-session on-glass to validate — deferred alongside the group-aware-exclusive on-glass item.
- Console arrangement table (web, x/y first); gamescope groups (single-output → decline extras, §6B).
*Validate:* two clients (probe + GTK) on the headless KDE box forming a 2-output desktop;
drag a window across; disconnect one → its slot lingers per policy, sibling unaffected,
restore only after both drop.
- **Console arrangement table (web)** — an x/y editor in the `Virtual displays` card reading
`/display/state` and writing `PUT /display/layout` (x/y table first; drag mini-map is the stretch).
The host API + persistence are done; this is the remaining web-only piece.
- **wlroots group-aware exclusive** stays deferred: wlroots `exclusive` is not implemented at all (needs
a Sway box), so there is no topology to make group-aware yet. §6A multi-view on wlroots already works
(independent `HEADLESS-N` outputs).
*Validate (all on-glass, needs a GPU box + 2 clients — not the dev VM):* two clients (probe + GTK) on
the headless KDE box forming a 2-output desktop; drag a window across; disconnect one → its slot lingers
per policy, sibling unaffected, restore only after both drop.
- **Stage 6 — §6B protocol + Linux host + GTK client.** `VIDEO_CAP_MULTI_DISPLAY`, control-
stream Add/Remove/DisplayAdded, per-flow nonce-salt derivation, per-display pipelines on
KWin/wlroots, input display-index routing, C ABI additions, GTK client multi-window