niri-scratchpad: fix close-while-stashed eviction + add live acceptance test

Refresh the patch with the destroy-path fix (a client dying while stashed is
now evicted instead of leaked/zombied), and add pkg/niri-scratchpad/tests/
acceptance.py: a headless IPC-driven regression test that drives a nested
patched niri and asserts on `niri msg -j windows`. It found the above bug
(unit tests miss it — the harness calls remove_window directly). Run it after
rebasing the patch onto a new niri release.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
funman300
2026-07-17 13:02:35 -07:00
parent d05b9f8a0e
commit 0e0b5fcbe3
2 changed files with 346 additions and 3 deletions
+36 -3
View File
@@ -56,6 +56,26 @@ index 0aa3cb4f..a5b712e2 100644
/// Toggle (open/close) the Overview.
ToggleOverview {},
/// Open the Overview.
diff --git a/src/handlers/xdg_shell.rs b/src/handlers/xdg_shell.rs
index 38440c90..a174921e 100644
--- a/src/handlers/xdg_shell.rs
+++ b/src/handlers/xdg_shell.rs
@@ -834,6 +834,15 @@ impl XdgShellHandler for State {
.find_window_and_output(surface.wl_surface());
let Some((mapped, output)) = win_out else {
+ // A window whose client died while stashed in the scratchpad is in no
+ // workspace, so find_window_and_output can't see it; evict it here.
+ if self
+ .niri
+ .layout
+ .remove_from_scratchpad(surface.wl_surface())
+ {
+ return;
+ }
// I have no idea how this can happen, but I saw it happen once, in a weird interaction
// involving laptop going to sleep and resuming.
error!("toplevel missing from both unmapped_windows and layout");
diff --git a/src/input/mod.rs b/src/input/mod.rs
index f1ad4932..91bc8942 100644
--- a/src/input/mod.rs
@@ -86,7 +106,7 @@ index f1ad4932..91bc8942 100644
self.niri.layout.toggle_overview();
self.niri.queue_redraw_all();
diff --git a/src/layout/mod.rs b/src/layout/mod.rs
index 5c4dd639..077a0570 100644
index 5c4dd639..a90d9e6e 100644
--- a/src/layout/mod.rs
+++ b/src/layout/mod.rs
@@ -32,6 +32,7 @@
@@ -149,7 +169,7 @@ index 5c4dd639..077a0570 100644
if let Some(state) = &self.interactive_move {
match state {
InteractiveMoveState::Starting { window_id, .. } => {
@@ -1204,6 +1222,93 @@ impl<W: LayoutElement> Layout<W> {
@@ -1204,6 +1222,106 @@ impl<W: LayoutElement> Layout<W> {
None
}
@@ -164,6 +184,19 @@ index 5c4dd639..077a0570 100644
+ // (Task 4) if this window was the one shown.
+ }
+
+ /// Evict a stashed window from the scratchpad by its surface. Returns true
+ /// if one was removed. Needed on the destroy/unmap paths: a window whose
+ /// client dies *while stashed* is in no workspace, so `find_window_and_output`
+ /// (and therefore `remove_window`) never sees it — this is the only thing
+ /// that drops it. A shown scratchpad window lives in a workspace and is
+ /// handled by `remove_window` instead.
+ pub fn remove_from_scratchpad(&mut self, surface: &WlSurface) -> bool {
+ let before = self.scratchpad.len();
+ self.scratchpad
+ .retain(|rt| !rt.tile.window().is_wl_surface(surface));
+ self.scratchpad.len() != before
+ }
+
+ /// Show / toggle / cycle the scratchpad. See the state table in the plan.
+ pub fn scratchpad_show(&mut self) {
+ // Drop a stale "shown" id (window was closed or moved away).
@@ -243,7 +276,7 @@ index 5c4dd639..077a0570 100644
pub fn descendants_added(&mut self, id: &W::Id) -> bool {
for ws in self.workspaces_mut() {
if ws.descendants_added(id) {
@@ -2444,6 +2549,24 @@ impl<W: LayoutElement> Layout<W> {
@@ -2444,6 +2562,24 @@ impl<W: LayoutElement> Layout<W> {
}
}