The retry loop stops eating the restore that re-lights the desk #430
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user