Repository: qtile/qtile

Issue:
Rogue windows left after Qtile restart after killing Keepassxc window
# Issue description

When I use `lazy.window.kill()` to kill Keepassxc' window and then restart Qtile, a rogue window is present and stuck to all screens, does not get managed by Qtile, and absorbs the pixels from the last thing that was above it. I should mention that Keepassxc does not get killed; it runs in the background and it's window can be re-created by clicking it's icon in the Systray. This also sometimes happens with [whalebird](https://github.com/h3poteto/whalebird-desktop) and Slack, both of which are electron apps.

# Qtile version

This is present on master (04be6f6a50baf8d28ad388eea2fa4b7e13817d52) but I checked on a few commits spanning the past year and the effort is always the same, and is very reproducible (with Keepassxc, after I figured out how to recreate it).

# Stack traces

There is nothing in either log but here are the xprops of the rogue keepassxc window:

```
_NET_WM_DESKTOP(CARDINAL) = 0
WM_STATE(WM_STATE):
		window state: Withdrawn
		icon window: 0x0
_NET_WM_USER_TIME(CARDINAL) = 1649670
_NET_WM_ICON(CARDINAL) = 
_NET_WM_ICON_NAME(UTF8_STRING) = 
XdndAware(ATOM) = BITMAP
WM_NAME(STRING) = "xc.kdbx [Locked] - KeePassXC"
_NET_WM_NAME(UTF8_STRING) = "xc.kdbx [Locked] - KeePassXC"
_MOTIF_WM_HINTS(_MOTIF_WM_HINTS) = 0x3, 0x3e, 0x7e, 0x0, 0x0
_NET_WM_WINDOW_TYPE(ATOM) = _NET_WM_WINDOW_TYPE_NORMAL
_XEMBED_INFO(_XEMBED_INFO) = 0x0, 0x1
WM_CLIENT_LEADER(WINDOW): window id # 0x600008
WM_HINTS(WM_HINTS):
		Client accepts input or input focus: True
		window id # of group leader: 0x600008
WM_CLIENT_MACHINE(STRING) = "zenbook"
_NET_WM_PID(CARDINAL) = 5894
_NET_WM_SYNC_REQUEST_COUNTER(CARDINAL) = 6291477
WM_CLASS(STRING) = "keepassxc", "KeePassXC"
WM_PROTOCOLS(ATOM): protocols  WM_DELETE_WINDOW, WM_TAKE_FOCUS, _NET_WM_PING, _NET_WM_SYNC_REQUEST
WM_NORMAL_HINTS(WM_SIZE_HINTS):
		user specified size: 625 by 469
		program specified minimum size: 625 by 366
		program specified maximum size: 16383 by 16383
		program specified resize increment: 1 by 1
		program specified base size: 0 by 0
		window gravity: Static

```

and the same for Whalebird:

```
_NET_WM_DESKTOP(CARDINAL) = 5
WM_STATE(WM_STATE):
		window state: Withdrawn
		icon window: 0x0
_NET_WM_USER_TIME(CARDINAL) = 138037
WM_NORMAL_HINTS(WM_SIZE_HINTS):
		program specified location: 460, 258
WM_NAME(UTF8_STRING) = "Whalebird"
_NET_WM_NAME(UTF8_STRING) = "Whalebird"
_NET_WM_ICON(CARDINAL) = 
XdndAware(ATOM) = BITMAP
_MOTIF_WM_HINTS(_MOTIF_WM_HINTS) = 0x2, 0x0, 0x1, 0x0, 0x0
_NET_WM_BYPASS_COMPOSITOR(CARDINAL) = 2
_NET_WM_SYNC_REQUEST_COUNTER(CARDINAL) = 18874370, 18874371
WM_WINDOW_ROLE(STRING) = "browser-window"
WM_CLASS(STRING) = "whalebird", "Whalebird"
_NET_WM_WINDOW_TYPE(ATOM) = _NET_WM_WINDOW_TYPE_NORMAL
_NET_WM_PID(CARDINAL) = 1063
WM_LOCALE_NAME(STRING) = "en_GB.UTF-8"
WM_CLIENT_MACHINE(STRING) = "zenbook"
WM_PROTOCOLS(ATOM): protocols  WM_DELETE_WINDOW, _NET_WM_PING, _NET_WM_SYNC_REQUEST
```

They both have `Withdrawn` window states. I think withdrawn windows are never meant to be visible?

# Configuration

There is no specific configuration required, although it is possibly due to the way `Window.kill` works.


Discussion:
I think this is because we don't actually support withdrawn windows (i.e. we set the window.state correctly, but we don't actually do anything about it). I think at the bare minimum we need something like this:

```
diff --git a/libqtile/window.py b/libqtile/window.py
index 1d10e60c..073f5a94 100644
--- a/libqtile/window.py
+++ b/libqtile/window.py
@@ -368,6 +368,10 @@ class _Window(CommandObject):
     def state(self, val):
         if val in (WithdrawnState, NormalState, IconicState):
             self.window.set_property('WM_STATE', [val, 0])
+        if state == WithdrawnState:
+            self.hide()
+        if state == NormalState:
+            self.unhide()
 
     def set_opacity(self, opacity):
         if 0.0 <= opacity <= 1.0:
@@ -422,7 +426,6 @@ class _Window(CommandObject):
 
     def unhide(self):
         self.window.map()
-        self.state = NormalState
         self.hidden = False
 
     @contextlib.contextmanager
```

But I think even that probably won't do it; layouts won't understand that the window is hidden (none of them check the hidden attribute). You could probably hack it by unmanaging (or at least removing) withdrawn windows from all layouts' list of windows.

But even better may be something like https://github.com/qtile/qtile/pull/1500 where we introduce a proxy for the "valid" windows (ClientList), and it can do this filtering for the layouts.
Reading through #1500 I agree that this would be best dealt with at the level of handing layouts a list of valid clients. This would have to be in addition to hiding any withdrawn windows, as in your snippet, otherwise they'll still hang around. As withdrawn windows can be un-withdrawn they shouldn't be unmanaged entirely, but rather just be excluded from everything while their state stays withdrawn.

In any case, when I see these withdrawn "windows" they are never involved in the tiling by the layout, and tiling of new windows works as normal, so they must be ignored somewhere, unless they have done something to get themselves unmanaged. I'll play around with that diff and see if that _appears_ to solve it, and then we can consider handling withdrawn windows properly with the outcome of #1500.
FWIW The diff didn't seem to make any difference.

Resolving patch:
diff --git a/libqtile/backend/x11/xcore.py b/libqtile/backend/x11/xcore.py
--- a/libqtile/backend/x11/xcore.py
+++ b/libqtile/backend/x11/xcore.py
@@ -209,6 +209,7 @@ def scan(self) -> None:
             if attrs and attrs.map_state == xcffib.xproto.MapState.Unmapped:
                 continue
             if state and state[0] == window.WithdrawnState:
+                item.unmap()
                 continue
             self.qtile.manage(item)
 


Test patch: