The retry loop stops eating the restore that re-lights the desk #430

Merged
enricobuehler merged 1 commits from worktree-hyprland-exclusive-restore-strand into main 2026-08-28 20:18:17 +00:00
4 changed files with 165 additions and 20 deletions
+114
View File
@@ -306,3 +306,117 @@ pub trait VirtualDisplay: Send {
true
}
}
/// Stash a freshly-prepared topology restore into a backend instance's pending slot, keeping the
/// **first** restore that instance ever captured.
///
/// One backend instance serves EVERY attempt of the host's pipeline retry loop (`native/stream.rs`
/// opens the display once and lends it to `build_pipeline_with_retry` for up to 8 attempts), so
/// `create` — and with it the backend's topology step — runs repeatedly against this one slot.
/// Attempt 1 disables the operator's heads and prepares the restore; attempts 2..n then *correctly*
/// find nothing left to disable and prepare `None`, because attempt 1 already darkened everything.
///
/// Assigning that `None` over the held restore is what left an `exclusive` Hyprland desk dark after
/// a failed build: attempt 1's closure was dropped rather than run, so by the time the failure
/// unwound and the backend dropped, its backstop had nothing to re-enable and only a hand-run
/// `hyprctl reload` brought the heads back. Skipping the assignment is the whole fix.
///
/// First-wins keeps the *right* list too, not merely a surviving one: attempt 1 looked at the desk
/// while it was still lit, so its set is every head that was on. Any later attempt can only see a
/// subset of that.
///
/// Backends whose restore the registry drains after each `create` (KWin — pooled, so
/// [`VirtualDisplay::take_topology_restore`] empties the slot) reach this with `None` held and are
/// unaffected; it matters for the pass-through backends (Hyprland, wlroots/sway carry a portal fd,
/// so the registry returns them before the take and the slot is never drained).
pub(crate) fn stash_topology_restore(
slot: &mut Option<Box<dyn FnOnce() + Send>>,
prepared: Option<Box<dyn FnOnce() + Send>>,
) {
if slot.is_none() {
*slot = prepared;
}
}
#[cfg(test)]
mod topology_restore_tests {
use super::stash_topology_restore;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
fn counting(hits: &Arc<AtomicUsize>) -> Option<Box<dyn FnOnce() + Send>> {
let hits = Arc::clone(hits);
Some(Box::new(move || {
hits.fetch_add(1, Ordering::SeqCst);
}))
}
/// The failure shape this exists for: the retry loop runs `create` eight times against ONE
/// backend instance, only the first of which has heads to disable — and the build then fails,
/// so the backstop `Drop` is the only thing that will ever run the restore. It must still be
/// holding one.
#[test]
fn eight_failed_attempts_do_not_strand_the_restore() {
let hits = Arc::new(AtomicUsize::new(0));
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
// Attempt 1: the desk was lit, two heads went dark, restore prepared.
stash_topology_restore(&mut slot, counting(&hits));
// Attempts 2..8: `disable_other_heads` correctly finds nothing enabled but the managed
// output, so each prepares `None`. None of them may take attempt 1's restore away.
for _ in 0..7 {
stash_topology_restore(&mut slot, None);
}
let restore = slot.expect("the retry loop stranded the restore — the desk stays dark");
restore();
assert_eq!(
hits.load(Ordering::SeqCst),
1,
"the restore must run exactly once, on the failure unwind"
);
}
/// A second prepared restore never displaces the first: attempt 1 saw the full set of lit
/// heads, a later one can only have seen a subset.
#[test]
fn a_later_restore_never_displaces_the_first() {
let first = Arc::new(AtomicUsize::new(0));
let second = Arc::new(AtomicUsize::new(0));
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
stash_topology_restore(&mut slot, counting(&first));
stash_topology_restore(&mut slot, counting(&second));
slot.expect("a restore should be held")();
assert_eq!(first.load(Ordering::SeqCst), 1, "the first must be kept");
assert_eq!(
second.load(Ordering::SeqCst),
0,
"the second must be dropped"
);
}
/// An empty slot still accepts one — including after the registry drained it (the KWin path),
/// so a pooled backend's later create can hand off a fresh restore as before.
#[test]
fn an_empty_slot_still_accepts_a_restore() {
let hits = Arc::new(AtomicUsize::new(0));
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
stash_topology_restore(&mut slot, counting(&hits));
let _drained = slot.take(); // the registry lifted it into the group
assert!(slot.is_none());
stash_topology_restore(&mut slot, counting(&hits));
assert!(slot.is_some(), "a drained slot must be refillable");
}
/// Nothing to disable and nothing held stays nothing held — an `extend`-shaped session must not
/// grow a restore out of thin air.
#[test]
fn nothing_prepared_leaves_the_slot_empty() {
let mut slot: Option<Box<dyn FnOnce() + Send>> = None;
stash_topology_restore(&mut slot, None);
assert!(slot.is_none());
}
}
@@ -143,19 +143,31 @@ pub struct HyprlandDisplay {
/// [`VirtualDisplay::last_portal_cursor_mode`], which is how the host learns that a cursor
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
/// physical head is never left dark. Mirrors `kwin.rs`'s field of the same name.
/// The topology-restore action the FIRST `create` on this instance prepared (re-enable the heads
/// an `exclusive` topology disabled). Written only through
/// [`stash_topology_restore`](crate::backend::stash_topology_restore) — first-wins, because one
/// instance serves every attempt of the host's pipeline retry loop and only attempt 1 finds
/// heads to disable.
///
/// ⚠️ Unlike KWin's field of the same name, this one is NOT picked up by the registry, and
/// [`Drop`] is therefore the ONLY thing that ever runs it — not a backstop. A Hyprland display
/// carries a portal fd, so `registry::acquire` returns it as pass-through *before* it reaches
/// `take_topology_restore()`; nothing lifts this into a display group. The comment that used to
/// sit here claimed the opposite, which is how a stranded restore read as a registry bug.
///
/// The live consequence of that (unfixed, separate from the strand): the restore is effectively
/// per-SESSION here, so two concurrent `exclusive` sessions sharing this desk will have the
/// first one to end re-enable the heads under the second. Closing it means giving the
/// pass-through path group bookkeeping it does not have today — #284's territory.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
impl Drop for HyprlandDisplay {
fn drop(&mut self) {
// Backstop only: the registry takes the restore right after `create` (moving it into the
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
// than strand the operator's heads dark.
// The ONLY path that runs it (see the field docs — the registry never takes a pass-through
// display's restore). This is what re-lights the desk when a pipeline build fails: the
// failure unwinds past `PreparedDisplay`, dropping the backend instance that still holds
// attempt 1's restore.
if let Some(restore) = self.pending_restore.take() {
restore();
}
@@ -172,7 +184,8 @@ impl HyprlandDisplay {
}
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
/// the restore for the registry (see [`Self::pending_restore`]).
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
/// take a pass-through display's restore).
///
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
/// after it, so there is no path that disables the operator's heads and then unwinds past the
@@ -187,9 +200,13 @@ impl HyprlandDisplay {
Topology::Primary => warn_primary_is_not_expressible(),
Topology::Exclusive => {
let disabled = disable_other_heads(ours);
self.pending_restore = (!disabled.is_empty()).then(|| {
let prepared = (!disabled.is_empty()).then(|| {
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
});
// Keep the FIRST restore, never the latest: the retry loop calls `create` up to
// eight times on this one instance, and only attempt 1 has heads to disable — so a
// plain assignment overwrote it with attempt 2's `None` and stranded the desk dark.
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
}
}
}
@@ -571,7 +571,7 @@ impl VirtualDisplay for KwinDisplay {
// sessions drops — under a still-live sibling). Instead stash it as a closure the registry lifts
// into the display group and runs once, when the group's LAST member is torn down (ordered before
// that display's output is reclaimed, so KWin never sees zero outputs). Empty ⇒ nothing to restore.
self.pending_restore = (!disabled.is_empty()).then(|| {
let prepared = (!disabled.is_empty()).then(|| {
let disabled = disabled.clone();
// In-process first; fall back to kscreen-doctor if the compositor doesn't answer in
// budget. **Both halves now return honest verdicts** — `reenable_outputs` reports
@@ -604,6 +604,11 @@ impl VirtualDisplay for KwinDisplay {
.ok();
}) as Box<dyn FnOnce() + Send>
});
// Keep the FIRST restore. KWin is registry-POOLED, so the registry drains this slot right
// after every `create` and it is normally empty here — but guard it the way the
// pass-through backends must, so a retry-loop create can never overwrite a held restore.
// See [`stash_topology_restore`].
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
// Layout position (§6.2) is applied by the registry via `apply_position` right after create
// (it owns the display group, so it computes auto-row / manual placement over the whole group).
let mut out = VirtualOutput::owned(
@@ -75,18 +75,22 @@ pub struct WlrootsDisplay {
/// overlay is never coming instead of inferring it from an absence.
last_cursor_mode: Option<crate::portal_cursor::Mode>,
/// The topology-restore action the last `create` prepared (re-enable the heads an `exclusive`
/// topology disabled), pending pickup by the registry via [`take_topology_restore`] — so the
/// operator's screens come back when the display GROUP's last member drops (design §6.1), not
/// when this one session ends. A backstop [`Drop`] runs it if the registry never took it, so a
/// physical head is never left dark. Mirrors `kwin.rs` and the Hyprland twin.
/// topology disabled). Written only through
/// [`stash_topology_restore`](crate::backend::stash_topology_restore) — first-wins, because one
/// instance serves every attempt of the host's pipeline retry loop and only attempt 1 finds
/// heads to disable.
///
/// ⚠️ As on the Hyprland twin (and unlike KWin), the registry never picks this up: a sway display
/// carries a portal fd, so `registry::acquire` returns it as pass-through before reaching
/// `take_topology_restore()`. [`Drop`] is the ONLY thing that runs it, with the same per-session
/// caveat for concurrent `exclusive` sessions noted there.
pending_restore: Option<Box<dyn FnOnce() + Send>>,
}
impl Drop for WlrootsDisplay {
fn drop(&mut self) {
// Backstop only: the registry takes the restore right after `create` (moving it into the
// group), so this is normally `None`. If some path skipped the take, re-enable here rather
// than strand the operator's heads dark.
// The ONLY path that runs it (see the field docs — the registry never takes a pass-through
// display's restore); it is what re-lights the desk when a pipeline build fails.
if let Some(restore) = self.pending_restore.take() {
restore();
}
@@ -103,7 +107,8 @@ impl WlrootsDisplay {
}
/// Apply the effective [`crate::policy::Topology`] for the just-created output `ours`, and stash
/// the restore for the registry (see [`Self::pending_restore`]).
/// the restore this instance runs on drop (see [`Self::pending_restore`] — the registry does not
/// take a pass-through display's restore).
///
/// Called at the very END of [`create`](VirtualDisplay::create), on purpose: nothing can fail
/// after it, so there is no path that disables the operator's heads and then unwinds past the
@@ -118,9 +123,13 @@ impl WlrootsDisplay {
Topology::Primary => warn_primary_is_not_expressible(),
Topology::Exclusive => {
let disabled = disable_other_heads(ours);
self.pending_restore = (!disabled.is_empty()).then(|| {
let prepared = (!disabled.is_empty()).then(|| {
Box::new(move || restore_heads(&disabled)) as Box<dyn FnOnce() + Send>
});
// Keep the FIRST restore, never the latest — the same retry-loop trap as the
// Hyprland twin, and sway is pass-through (portal fd) too, so this slot is likewise
// never drained by the registry. See [`stash_topology_restore`].
crate::backend::stash_topology_restore(&mut self.pending_restore, prepared);
}
}
}