2021.06.04 - Using Other Window Managers with Plasma 5.21 and systemd Startup

While updating from Fedora 33 to 34 I noticed my Desktop setup didn’t work anymore. It’s a bit weird; I use KDE as desktop environment, but I changed the default KDE window manager (KWin) for i3.

This way I can take advantage of the many niceties of a full blown DE - power management on my laptop, Bluetooth, WLAN, … - but also use a ‘proper’ tiling window manager (honestly, I’ve used i3 in my daily workflow for so long now, it gets difficult to use anything else but that).

After some digging I found out the change that broke my setup was that KDE/Plasma now uses systemd user sessions and unit files to start now. I don’t mind systemd, but I wanted my config back, so I spend some time digging and found a good workaround to get it back.

The following is taken from my GitHub Gist dd5a3ca951f26137d63dadb0b92f6027.

(as of Thu Jun 17 18:41:20 2021 +0200)

Using Other Window Managers with Plasma 5.21 and systemd Startup

KDE Plasma on X offers the option to use a differnt window manager than the default KWin. This way one can use many of the integrations KDE offers, but with for example the i3 window manager to have proper tiling support. This only works on X, not on Wayland.

With Plasma 5.21 it is now possible to “boot” KDE using systemd user services; that is, KDE Plasma provides a bunch of unit files that get started atfer you login in your graphical user session. Inidividual components, such as KWin, have individual unit files, and dependencies are specified using the usual systemd relationships between units. Among other things this allows for better ressource control using automatically created CGroups.

More details here: https://invent.kde.org/plasma/plasma-workspace/-/merge_requests/47

Sadly this breaks the way one could change the window manager, that was used until now. I noticed after I updated my Fedora 33 workstation to Fedora 34, and finding my setup didn’t work anymore.

The Old Way

The old method to change the window manager is described in several places, among other this Wiki page: https://userbase.kde.org/Tutorials/Using_Other_Window_Managers_with_Plasma.

Essentially one would somehow set the environment variable ${KDEWM} to something different than kwin - e.g. export KDEWM=/usr/bin/i3 - very early during the KDE Plasma “boot”. For more details see the article above. Neither of the described methods that use ${KDEWM} work anymore; the corresponding code (https://invent.kde.org/plasma/plasma-workspace/-/blob/d0de8b9972d9/startkde/plasma-session/startup.cpp#L235) is not executed anymore AFAICT.

Workaround (A) - Disable systemd Startup

The first workaround I discovered was via this bugreport for Fedora 34: https://bugzilla.redhat.com/show_bug.cgi?id=1939291. It basically disables the new systemd startup mode, and reverts to the old mode of operations; which also allows the same way to change the window manager again.

Edit the file /etc/xdg/startkderc and set systemdBoot=false (https://invent.kde.org/plasma/plasma-workspace/-/blob/d0de8b9972d9/startkde/startplasma.cpp#L490). Like so:

[General]
systemdBoot=false

After that, just restart KDE and use the old way of change the window manager.

Workaround (B) - Change Wanted Units

The second workaround keeps the systemd startup mode activated, but changes what units are started during activation of the graphical user session.

The main target that KDE Plasma uses to kick off the graphical user session is plasma-workspace@x11.target (you can see that here: https://invent.kde.org/plasma/plasma-workspace/-/blob/d0de8b9972d9/startkde/startplasma.cpp#L592; the unit file is here: https://invent.kde.org/plasma/plasma-workspace/-/blob/d0de8b9972d9/startkde/systemd/plasma-workspace@.target).

It has a Wants relationship on plasma-kwin_x11.service. We need to disable that, so we can start a different window manager instead:

systemctl --user mask plasma-kwin_x11.service

Next we need a unit file for the new window manager (I use i3 as example). Create on in your home directory: ~/.config/systemd/user/i3.service

[Unit]
Description=i3 Window Manager
Wants=plasma-kcminit.service
PartOf=graphical-session.target

[Service]
ExecStart=/usr/bin/i3
Slice=session.slice
Restart=on-failure

Now add a Wants relationship for the new unit file, so its started when plasma-workspace@x11.target starts:

systemctl --user daemon-reload
systemctl --user add-wants plasma-workspace@x11.target i3.service

If you restart your graphical user session it should use the new window manager instead of the KWin.

Application Ressource Management for (B)

While I think workaround (B) is better then (A), because its in the spirit of the direction KDE probably wants to go, it has one drawback: processes you start with the new windows manager - for example if you use i3, you probably use dmenu to start applications - get assigned to the same .service as the window manager. If you only use Krunner or the KDE Application Launcher to start programs, you are already fine.

For example: systemctl --user status

.....
│ ├─i3.service
│ │ ├─41369 /usr/bin/i3
│ │ ├─41392 i3bar --bar_id=bar-0 --socket=/run/user/1000/i3/ipc-socket.00000
│ │ ├─41403 i3status
│ │ ├─42123 /usr/bin/alacritty
│ │ ├─42129 /bin/bash
│ │ ├─43098 /usr/bin/alacritty
│ │ ├─43104 /bin/bash
│ │ ├─43247 /usr/bin/alacritty
│ │ ├─43253 /bin/bash
│ │ ├─43624 /usr/lib64/firefox/firefox -P default
│ │ ├─49047 /usr/bin/alacritty
│ │ ├─49053 /bin/bash
│ │ ├─49509 systemctl --user status
│ │ └─49510 vim -
.....

You see all the terminals and firefox being a child of the i3.service. This happens because I started all these either by using the shortcut for starting a terminal in i3, or because I started the using demnu, which is started by a shortcut in i3 - the shortcuts from i3 trigger childprocesses of i3, which naturally get assigned to the same systemd unit. But this has implications on the ressource management. Because all these are children of i3.service, they are part of the session.slice (see the unit file example above).

If you look into the manpage (https://www.freedesktop.org/software/systemd/man/systemd.special.html#Special%20User%20Slice%20Units), that is not where they are supposed to be. Applications are supposed to be managed under the app.slice, and this is even true for all the programs you start via KDE Plasma (either via the Application Launcher, or for example KRunner), because Plasma takes care of assigning them to the correct slice.

So if you use your new window manager to create child processes you have to take some extra effort to assign them to the correct slice.

Starting Child Porcesses in app.slice with i3

On easy way to script this is to make use of systemd-run (https://www.freedesktop.org/software/systemd/man/systemd-run.html), it provides all the necessary options already. I wrote this example here for my own i3 setup, so I don’t know how useful it is for other window managers; and I mainly use dmenu to start programs, so its based on that.

Here is the main script of it all: dmenu_run_systemd

#!/bin/bash
# SPDX-License-Identifier: MIT
#
# dmenu_run_systemd: start a program from dmenu as transient systemd .scope
# (C) Copyright Benjamin Block 2021
#
# Permission is hereby granted, free of charge, to any person obtaining a
# copy of this software and associated documentation files (the "Software"),
# to deal in the Software without restriction, including without limitation
# the rights to use, copy, modify, merge, publish, distribute, sublicense,
# and/or sell copies of the Software, and to permit persons to whom the
# Software is furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice (including the next
# paragraph) shall be included in all copies or substantial portions of the
# Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
# THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
# Requirements:
#   - Package: bash
#   - Package: dmenu
#     - dmenu_path
#     - dmenu
#   - Package: coreutils
#     - basenc
#     - tr
#   - Package: systemd
#     - systemd-run
#   - Package: util-linux
#     - getopt
#
# Usage: dmenu_run_systemd
#
#   Start demnu to select program to execute, then start selected program in
#   background.
#
# Usage: dmenu_run_systemd [Options] [--] <command> [<arg1>[, <arg2>[...]]]
#
#   Start <command> with <arg1..N> without involving dmenu.
#   E.g. in i3 config: `bindsym $mod+Return exec dmenu_run_systemd alacritty`.
#
#   Options:
#    -f, --forground  Start <command> as forground task (default: no)
#    -p, --pwd        Use the current ${PWD} as working directory (default:
#                     ${HOME})

declare -g prefix forground=false cpwd=false
declare -ga selection
if [ "${#}" -lt 1 ]; then
     prefix="dmenu-"
     selection=("$(dmenu_path | dmenu)")                     || exit 127
else
     prefix="xrun-"

     declare opts
     opts="$(getopt --shell bash                                     \
                     -o "fp"                                         \
                     -l "forground,pwd"                              \
                     -n "dmenu_run_systemd" -- "${@}")"      || exit 122
     eval set -- "${opts}"
     unset opts

     while true; do
             case "${1}" in
             '-f'|'--forground')
                     forground=true
             ;;
             '-p'|'--pwd')
                     cpwd=true
             ;;
             '--')   shift; break;;
             esac
             shift
     done

     selection=("${@}")
fi
readonly selection prefix

declare -g name
# Max unit name length:     256
#        -    ".scope"   -    6
#        - "<prefix>-"   -    6
#        -   "-<rand>"   -   33
#                        ------
#            "<name>" <=    211
read -r -d '' -n 192 name < <(
     echo -n "${selection[*]}" | tr -c 'a-zA-Z0-9_-' '[_*]'  || exit 1
     echo -e '\0'                                            || exit 2
)                                                            || exit 126
readonly name
{ [ "${#name}" -gt 0 ] && [ "${#name}" -le 211 ]; }          || exit 125

declare -g rand
# ~5 bits per character => 32*5 = ~160 bits random number
read -r -N 32 rand < <(basenc --base32 < /dev/urandom)               || exit 124
readonly rand
[ "${#rand}" -eq 32 ]                                                || exit 123

declare -ga runargs=(
     --quiet
     --user                          # run in per-User slice
     --scope                         # create transient `.scope` unit,
                                     # instead of `.service`
     --collect                       # garbage collect everything after run,
                                     # even on failure
     --slice="app.slice"             # run as part of `app.slice`
     --unit="${prefix}${name}-${rand}"
                                     # unit name
     --description="dmenu selection ${selection[*]@Q}"
)

if ${cpwd}; then
     runargs+=( --working-directory="${PWD:-/}" )
else
     runargs+=( --working-directory="${HOME:-/}" )
fi

readonly runargs

## Debugging:
#declare -p prefix selection name rand runargs

if ${forground}; then
     systemd-run "${runargs[@]}" -- "${selection[@]}"
else
     systemd-run "${runargs[@]}" -- "${selection[@]}" &
fi

I put that script into some directory that is part of my ${PATH} variable (such as ~/bin, /usr/local/bin, …), and then changed my i3 configuration (~/.config/i3/config) to use dmenu_run_systemd to start programs:

# start a terminal
bindsym $mod+Return exec dmenu_run_systemd alacritty

# start dmenu (a program launcher)
bindsym $mod+d exec dmenu_run_systemd

Now if I start programs, each will get its own transient systemd scope. This bundles all sub- and co-processes of a program together into its own unique scope; and assignes them to my users app.slice.

Here is an example how this looks like on my machine: systemctl --user status

CGroup: /user.slice/user-1000.slice/user@1000.service
        ├─session.slice
        ....
        ├─background.slice
        ....
        ├─uresourced.service
        ....
        ├─app.slice
        │ ├─dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope
        │ │ ├─113644 /usr/lib64/firefox/firefox -P default
        │ │ ├─113849 /usr/lib64/firefox/firefox -contentproc -childID 2 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─113933 /usr/lib64/firefox/firefox -contentproc -childID 3 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─114000 /usr/lib64/firefox/firefox -contentproc -childID 4 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─114058 /usr/lib64/firefox/firefox -contentproc -childID 5 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─114263 /usr/lib64/firefox/firefox -contentproc -childID 10 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─114416 /usr/lib64/firefox/firefox -contentproc ... -appdir /usr/lib64/firefox/browser 113644 true rdd
        │ │ ├─114516 /usr/lib64/speech-dispatcher-modules/sd_dummy /etc/speech-dispatcher/modules/dummy.conf
        │ │ ├─114519 /usr/lib64/speech-dispatcher-modules/sd_espeak-ng /etc/speech-dispatcher/modules/espeak-ng.conf
        │ │ ├─114530 /usr/bin/speech-dispatcher --spawn --communication-method unix_socket --socket-path /run/user/1000/speech-dispatcher/speechd.sock
        │ │ ├─125864 /usr/lib64/firefox/firefox -contentproc -childID 20 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─125996 /usr/lib64/firefox/firefox -contentproc -childID 21 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ ├─126070 /usr/lib64/firefox/firefox -contentproc -childID 22 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        │ │ └─132748 /usr/lib64/firefox/firefox -contentproc -childID 23 -isForBrowser ... -appdir /usr/lib64/firefox/browser 113644 true tab
        ....
        │ ├─dmenu-cantata-DGJ2OB4ZGR3ZHZBWWYMI4YCPQ2HJOQLQ.scope
        │ │ └─128981 /usr/bin/cantata
        ....
        │ ├─xrun-alacritty-6MO43KRGOLYYJQEH537LMNFJ6NINJFJI.scope
        │ │ ├─135787 /usr/bin/alacritty
        │ │ ├─135793 /bin/bash
        │ │ ├─137217 systemctl --user status
        │ │ └─137218 gvim -
        ....
        └─init.scope
          ├─101906 /usr/lib/systemd/systemd --user
        ....

You see that programs I started with the script (shortcuts in i3) have their own unique (transient) systemd scope. The script generates a pseudo random number each time it is called, so if you execute the same program multiple times, each instance gets its own scope. And all the sub- and co-processes of a program get bundles into the same scope - all the firefox processes in the scope dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope belong to the same browser instance.

This makes it also easy to observe ressources and messages from individual program instances, because they are associated with its own particular scope.

For example: systemctl --user status dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope

● dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope - dmenu selection 'firefox'
     Loaded: loaded (/run/user/1000/systemd/transient/dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope; transient)
  Transient: yes
     Active: active (running) since Aaa 0000-00-00 00:00:00 CEST; 00h ago
      Tasks: 457 (limit: 37704)
     Memory: 2.3G
        CPU: 18min 55.126s
     CGroup: /user.slice/user-1000.slice/user@1000.service/app.slice/dmenu-firefox-73T6N7M56SVHVISXLGNRCYWXEPB663P3.scope
             ├─113644 /usr/lib64/firefox/firefox -P default
             ....

Aaa 00 00:00:00 aaaa-aaaaaaaa systemd[101906]: Started dmenu selection 'firefox'.

I think thats pretty neat.