Hyprland and wlroots accept topology: primary/exclusive, echo it back, and silently do nothing #284

Closed
opened 2026-08-17 12:31:12 +00:00 by enricobuehler · 0 comments
Owner

Split out of #283, which fixed the symptom an operator actually hits (library launches opening on the physical head) but deliberately left the topology axis unimplemented.

The state today

Topology has three concrete levels — extend, primary, exclusive — plus auto. Five backends apply them. Two do not: Hyprland and wlroots/sway accept the setting, the management API echoes it back as the session's effective topology, and the backend drops it with a warning:

// crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs  (wlroots.rs is the twin)
/// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per
/// create instead of leaving the management API's echo as the only signal that the pin was dropped
/// (sweep 13.18). The Hyprland headless output is always an EXTENSION: [`focus_output`] steers new
/// windows onto it, but nothing here promotes it to primary or disables the operator's heads.
fn warn_topology_is_extend_only() {  }

This is sweep item 13.18 (punktfunk-planning/design/pf-vdisplay-sweep.md). Worth being precise about what landed for it: the item asked for the axis to stop being silently dropped, and the shipped fix was the warning only. The behaviour was never implemented, and the checkbox reading as addressed is part of why this is easy to lose track of.

Why it bites harder than "an unimplemented setting"

resolve_topology makes auto — the default — resolve to Exclusive on any host without a PUNKTFUNK_COMPOSITOR pin:

// crates/pf-vdisplay/src/lib.rs
policy::Topology::Auto => {
    if pf_host_config::config().compositor.is_some() {
        policy::Topology::Extend
    } else {
        policy::Topology::Exclusive
    }
}

Hyprland and sway are auto-detected, so they are not pinned, so the default policy on every such box is an Exclusive that does nothing. The console shows Exclusive, /display/state reports Exclusive, and the box behaves as Extend. The docs' own per-backend table says Primary is "⚠️ treated as Extend" and Exclusive is " following release", so the documented intent is already that these should land.

What "implemented" means here

punktfunk-planning/design/pf-vdisplay-sweep.md §5.2 already specifies the mapping, and calls it "new, small":

extend primary exclusive
wlroots no-op unsupported (no primary concept) → log + treat as extend swaymsg output <phys> disable + re-enable on teardown

So the work is:

  • primary — genuinely unsupported on both: Wayland has no primary-output concept, and Hyprland has only a focused monitor. The honest implementation is to keep treating it as extend, but say so once per create rather than lumping it in with exclusive (#283's focus claim is arguably already the closest thing to primary these compositors can express).
  • exclusive — disable the non-managed heads for the session and restore them on teardown. hyprctl keyword monitor <name>,disable / swaymsg output <name> disable.

Constraints worth carrying over from the backends that shipped it

  • Group-aware, not blanket. "Exclusive" means the managed virtual displays are the only enabled outputs — it must never disable a sibling session's slot. KWin's filter is the reference (§6.1); a second session's exclusive blacking out the first session's output is a bug Stage 3 introduced there and Stage 5 fixed.
  • Never let the compositor see zero enabled outputs. KWin's restore deliberately re-enables the physical heads before the virtual one goes away. Same ordering applies here, and on Hyprland it compounds with a known hazard: teardown order is already load-bearing because removing a captured output while xdph is still casting it wedges xdph in a hot spin (#240).
  • Restore is bound to display teardown, not session end — so keep-alive inherits it for free, as everywhere else.
  • Restore needs the pre-session state recorded. hyprctl -j monitors all lists disabled heads too (that is why list_monitors uses all), so the state is readable; on Hyprland, keyword monitor sets a runtime override, so restoring means re-applying the head's own mode/position/scale/transform rather than assuming a hyprctl reload is safe — reload would also drop the monitor rule for our headless output and any other runtime keyword the operator set.
  • keep_alive + exclusive leaves the physical monitors dark after disconnect. Already documented as intentional for a dedicated box; it becomes true for two more backends here.

Not blocked on, but relevant

Nothing restores the operator's previously-focused monitor at teardown either (OutputGuard::drop only removes the output, and the compositor then picks a replacement head). That is a smaller sibling gap and would naturally be fixed alongside the exclusive restore path.

Validation

Hyprland can be exercised headless in a transient scope, no GPU session and no host build needed — this is how #283's focus contract was confirmed on Hyprland 0.56.2:

systemd-run --user --collect --unit=pf-hyprprobe --setenv=AQ_HEADLESS_ONLY=1 \
  --setenv=XDG_RUNTIME_DIR=/run/user/1000 Hyprland -c /tmp/min.conf
# HYPRLAND_INSTANCE_SIGNATURE=$(ls -1t /run/user/1000/hypr | head -1)

⚠️ Hyprland does not always clean its instance dir up on stop — it can leave a socket-less $XDG_RUNTIME_DIR/hypr/<sig>/, which is the same directory the host discovers signatures from. Remove it after a probe.

sway has no on-glass coverage in the fleet at all (.21 has Hyprland but no sway/swaymsg; .138 is the NixOS Hyprland box). The wlroots half of #283 shipped unverified for that reason, and the same gap applies to anything done here — §5.2's own status notes list wlroots exclusive as "needs a Sway box". Standing up one is arguably a prerequisite.

Split out of #283, which fixed the *symptom* an operator actually hits (library launches opening on the physical head) but deliberately left the topology axis unimplemented. ## The state today `Topology` has three concrete levels — `extend`, `primary`, `exclusive` — plus `auto`. Five backends apply them. **Two do not:** Hyprland and wlroots/sway accept the setting, the management API echoes it back as the session's effective topology, and the backend drops it with a warning: ```rust // crates/pf-vdisplay/src/vdisplay/linux/hyprland.rs (wlroots.rs is the twin) /// The configured [`crate::policy::Topology`] is not implemented on this backend — say so once per /// create instead of leaving the management API's echo as the only signal that the pin was dropped /// (sweep 13.18). The Hyprland headless output is always an EXTENSION: [`focus_output`] steers new /// windows onto it, but nothing here promotes it to primary or disables the operator's heads. fn warn_topology_is_extend_only() { … } ``` This is sweep item **13.18** (`punktfunk-planning/design/pf-vdisplay-sweep.md`). Worth being precise about what landed for it: the item asked for the axis to stop being *silently* dropped, and the shipped fix was **the warning only**. The behaviour was never implemented, and the checkbox reading as addressed is part of why this is easy to lose track of. ## Why it bites harder than "an unimplemented setting" `resolve_topology` makes `auto` — the **default** — resolve to `Exclusive` on any host without a `PUNKTFUNK_COMPOSITOR` pin: ```rust // crates/pf-vdisplay/src/lib.rs policy::Topology::Auto => { if pf_host_config::config().compositor.is_some() { policy::Topology::Extend } else { policy::Topology::Exclusive } } ``` Hyprland and sway are auto-detected, so they are *not* pinned, so the default policy on every such box is an **Exclusive that does nothing**. The console shows Exclusive, `/display/state` reports Exclusive, and the box behaves as Extend. The docs' own per-backend table says `Primary` is "⚠️ treated as Extend" and `Exclusive` is "⏳ following release", so the documented intent is already that these should land. ## What "implemented" means here `punktfunk-planning/design/pf-vdisplay-sweep.md` §5.2 already specifies the mapping, and calls it "new, small": | | extend | primary | exclusive | |---|---|---|---| | wlroots | no-op | **unsupported** (no primary concept) → log + treat as extend | `swaymsg output <phys> disable` + re-enable on teardown | So the work is: - **`primary`** — genuinely unsupported on both: Wayland has no primary-output concept, and Hyprland has only a *focused* monitor. The honest implementation is to keep treating it as extend, but say so once per create rather than lumping it in with `exclusive` (#283's focus claim is arguably already the closest thing to `primary` these compositors can express). - **`exclusive`** — disable the non-managed heads for the session and restore them on teardown. `hyprctl keyword monitor <name>,disable` / `swaymsg output <name> disable`. ## Constraints worth carrying over from the backends that shipped it - **Group-aware, not blanket.** "Exclusive" means *the managed virtual displays* are the only enabled outputs — it must never disable a sibling session's slot. KWin's filter is the reference (§6.1); a second session's exclusive blacking out the first session's output is a bug Stage 3 introduced there and Stage 5 fixed. - **Never let the compositor see zero enabled outputs.** KWin's restore deliberately re-enables the physical heads *before* the virtual one goes away. Same ordering applies here, and on Hyprland it compounds with a known hazard: teardown order is already load-bearing because removing a captured output while xdph is still casting it wedges xdph in a hot spin (#240). - **Restore is bound to display teardown**, not session end — so keep-alive inherits it for free, as everywhere else. - **Restore needs the pre-session state recorded.** `hyprctl -j monitors all` lists disabled heads too (that is why `list_monitors` uses `all`), so the state is readable; on Hyprland, `keyword monitor` sets a *runtime override*, so restoring means re-applying the head's own mode/position/scale/transform rather than assuming a `hyprctl reload` is safe — reload would also drop the monitor rule for our headless output and any other runtime keyword the operator set. - **`keep_alive` + `exclusive` leaves the physical monitors dark after disconnect.** Already documented as intentional for a dedicated box; it becomes true for two more backends here. ## Not blocked on, but relevant Nothing restores the operator's previously-focused monitor at teardown either (`OutputGuard::drop` only removes the output, and the compositor then picks a replacement head). That is a smaller sibling gap and would naturally be fixed alongside the exclusive restore path. ## Validation Hyprland can be exercised **headless in a transient scope**, no GPU session and no host build needed — this is how #283's focus contract was confirmed on Hyprland 0.56.2: ```sh systemd-run --user --collect --unit=pf-hyprprobe --setenv=AQ_HEADLESS_ONLY=1 \ --setenv=XDG_RUNTIME_DIR=/run/user/1000 Hyprland -c /tmp/min.conf # HYPRLAND_INSTANCE_SIGNATURE=$(ls -1t /run/user/1000/hypr | head -1) ``` ⚠️ Hyprland does not always clean its instance dir up on stop — it can leave a socket-less `$XDG_RUNTIME_DIR/hypr/<sig>/`, which is the same directory the host discovers signatures from. Remove it after a probe. sway has **no on-glass coverage in the fleet at all** (`.21` has Hyprland but no `sway`/`swaymsg`; `.138` is the NixOS Hyprland box). The wlroots half of #283 shipped unverified for that reason, and the same gap applies to anything done here — §5.2's own status notes list wlroots `exclusive` as "needs a Sway box". Standing up one is arguably a prerequisite.
Sign in to join this conversation.
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#284