My X11 setup with systemd
Somewhere along the way, I decided to use systemd user sessions to manage the various parts of my X11 environment would be a good idea. If that was a good idea or not… we’ll see.
I’ve sort-of been running this setup as my daily-driver for a bit over a year, continually tweaking it though.
My setup is substantially different than the one on ArchWiki,
because the ArchWiki solution assumes that there is only ever one X
server for a user; I like the ability to run Xorg
on my
real monitor, and also have Xvnc
running headless, or start
my desktop environment on a remote X server. Though, I would like to
figure out how to use systemd socket activation for the X server, as the
ArchWiki solution does.
This means that all of my graphical units take DISPLAY
as an @
argument. To get this to all work out, this goes in
each .service
file, unless otherwise noted:
[Unit]
After=X11@%i.target
Requisite=X11@%i.target
[Service]
Environment=DISPLAY=%I
We’ll get to X11@.target
later, what it says is “I
should only be running if X11 is running”.
I eschew complex XDMs or startx
wrapper scripts, opting
for the more simple xinit
, which I either run on login for
some boxes (my media station), or type xinit
when I want
X11 on others (most everything else). Essentially, what
xinit
does is run ~/.xserverrc
(or
/etc/X11/xinit/xserverrc
) to start the server, then once
the server is started (which it takes a substantial amount of magic to
detect) it runs run ~/.xinitrc
(or
/etc/X11/xinit/xinitrc
) to start the clients. Once
.xinitrc
finishes running, it stops the X server and exits.
Now, when I say “run”, I don’t mean execute, it passes each file to the
system shell (/bin/sh
) as input.
Xorg requires a TTY to run on; if we log in to a TTY with
logind
, it will give us the XDG_VTNR
variable
to tell us which one we have, so I pass this to X
in my
.xserverrc
:
#!/hint/sh
if [ -z "$XDG_VTNR" ]; then
exec /usr/bin/X -nolisten tcp "$@"
else
exec /usr/bin/X -nolisten tcp "$@" vt$XDG_VTNR
fi
This was the default for a
while in Arch, to support logind
, but was later
removed in part because startx
(which calls
xinit
) started adding it as an argument as well, so
vt$XDG_VTNR
was being listed as an argument twice, which is
an error. IMO, that was a problem in startx
, and they
shouldn’t have removed it from the default system
xserverrc
, but that’s just me. So I copy/pasted it into my
user xserverrc
.
That’s the boring part, though. Where the magic starts happening is
in my
.xinitrc
:
#!/hint/sh
if [ -z "$XDG_RUNTIME_DIR" ]; then
printf "XDG_RUNTIME_DIR isn't set\n" >&2
exit 6
fi
_DISPLAY="$(systemd-escape -- "$DISPLAY")"
trap "rm -f $(printf '%q' "${XDG_RUNTIME_DIR}/x11-wm@${_DISPLAY}")" EXIT
mkfifo "${XDG_RUNTIME_DIR}/x11-wm@${_DISPLAY}"
cat < "${XDG_RUNTIME_DIR}/x11-wm@${_DISPLAY}" &
systemctl --user start "X11@${_DISPLAY}.target" &
wait
systemctl --user stop "X11@${_DISPLAY}.target"
There are two contracts/interfaces here: the
X11@DISPLAY.target
systemd target, and the
${XDG_RUNTIME_DIR}/x11-wm@DISPLAY
named pipe. The systemd
.target
should be pretty self explanatory; the most
important part is that it starts the window manager. The named pipe is
just a hacky way of blocking until the window manager exits
(“traditional” .xinitrc
files end with the line
exec your-window-manager
, so this mimics that behavior). It
works by assuming that the window manager will open the pipe at startup,
and keep it open (without necessarily writing anything to it); when the
window manager exits, the pipe will get closed, sending EOF to the
wait
ed-for cat
, allowing it to exit, letting
the script resume. The window manager (WMII) is made to have the pipe
opened by executing it this way in its
.service
file:
ExecStart=/usr/bin/env bash -c 'exec 8>${XDG_RUNTIME_DIR}/x11-wm@%I; exec wmii'
which just opens the file on file descriptor 8, then launches the window manager normally. The only further logic required by the window manager with regard to the pipe is that in the window manager configuration, I should close that file descriptor after forking any process that isn’t “part of” the window manager:
runcmd() (
...
exec 8>&- # xinit/systemd handshake
...
)
So, back to the X11@DISPLAY.target
; I configure what it
“does” with symlinks in the .requires
and
.wants
directories:
-
- X11@.target
- X11@.target.requires/
- wmii@.service -> ../wmii@.service
- X11@.target.wants/
- xmodmap@.service -> ../xmodmap@.service
- xresources-dpi@.service -> ../xresources-dpi@.service
- xresources@.service -> ../xresources@.service
The .requires
directory is how I configure which window
manager it starts. This would allow me to configure different window
managers on different displays, by creating a .requires
directory with the DISPLAY
included,
e.g. X11@:2.requires
.
The .wants
directory is for general X display setup;
it’s analogous to /etc/X11/xinit/xinitrc.d/
. All of the
files in it are simple Type=oneshot
service files. The xmodmap
and xresources
files are pretty boring, they’re just systemd versions of the couple
lines that just about every traditional .xinitrc
contains,
the biggest difference being that they look at ~/.config/X11/modmap
and ~/.config/X11/resources
instead of the traditional locations ~/.xmodmap
and
~/.Xresources
.
What’s possibly of note is xresources-dpi@.service
.
In X11, there are two sources of DPI information, the X display
resolution, and the XRDB Xft.dpi
setting. It isn’t defined
which takes precedence (to my knowledge), and even if it were (is),
application authors wouldn’t be arsed to actually do the right thing.
For years, Firefox (well, Iceweasel) happily listened to the X display
resolution, but recently it decided to only look at
Xft.dpi
, which objectively seems a little silly, since the
X display resolution is always present, but Xft.dpi
isn’t.
Anyway, Mozilla’s change drove me to to create a script
to make the Xft.dpi
setting match the X display resolution.
Disclaimer: I have no idea if it works if the X server has multiple
displays (with possibly varying resolution).
#!/usr/bin/env bash
dpi=$(LC_ALL=C xdpyinfo|sed -rn 's/^\s*resolution:\s*(.*) dots per inch$/\1/p')
xrdb -merge <<<"Xft.dpi: ${dpi}"
Since we want XRDB to be set up before any other programs launch, we
give both of the xresources
units
Before=X11@%i.target
(instead of After=
like
everything else). Also, two programs writing to xrdb
at the
same time has the same problem as two programs writing to the same file;
one might trash the other’s changes. So, I stuck
Conflicts=xresources@:i.service
into
xresources-dpi.service
.
And that’s the “core” of my X11 systemd setup. But, you generally
want more things running than just the window manager, like a desktop
notification daemon, a system panel, and an X composition manager
(unless your window manager is bloated and has a composition manager
built in). Since these things are probably window-manager specific, I’ve
stuck them in a directory wmii@.service.wants
:
-
- wmii@.service.wants/
- dunst@.service -> ../dunst@.service # a notification daemon
- lxpanel@.service -> ../lxpanel@.service # a system panel
- rbar@97_acpi.service -> ../rbar@.service # wmii stuff
- rbar@99_clock.service -> ../rbar@.service # wmii stuff
- xcompmgr@.service -> ../xcompmgr@.service # an X composition manager
- wmii@.service.wants/
For the window manager .service
, I could just
say Type=simple
and call it a day (and I did for a while).
But, I like to have lxpanel
show up on all of my WMII tags
(desktops), so I have my
WMII configuration stick this in the WMII /rules
:
/panel/ tags=/.*/ floating=always
Unfortunately, for this to work, lxpanel
must be started
after that gets inserted into WMII’s rules. That wasn’t a
problem pre-systemd, because lxpanel
was started by my WMII
configuration, so ordering was simple. For systemd to get this right, I
must have a way of notifying systemd that WMII’s fully started, and it’s
safe to start lxpanel
. So, I stuck this in my
WMII .service
file:
# This assumes that you write READY=1 to $NOTIFY_SOCKET in wmiirc
Type=notify
NotifyAccess=all
and this in my WMII configuration:
systemd-notify --ready || true
Now, this setup means that NOTIFY_SOCKET
is set for all
the children of wmii
; I’d rather not have it leak into the
applications that I start from the window manager, so I also stuck
unset NOTIFY_SOCKET
after forking a process that isn’t part
of the window manager:
runcmd() (
...
unset NOTIFY_SOCKET # systemd
...
exec 8>&- # xinit/systemd handshake
...
)
Unfortunately, because of a couple of bugs and race
conditions in systemd, systemd-notify
isn’t reliable.
If systemd can’t receive the READY=1
signal from my WMII
configuration, there are two consequences:
lxpanel
will never start, because it will always be waiting forwmii
to be ready, which will never happen.- After a couple of minutes, systemd will consider
wmii
to be timed out, which is a failure, so then it will killwmii
, and exit my X11 session. That’s no good!
Using socat
to send the message to systemd instead of
systemd-notify
“should” always work, because it tries to
read from both ends of the bi-directional stream, and I can’t imagine
that getting EOF from the UNIX-SENDTO
end will ever be
faster than the systemd manager from handling the datagram that got
sent. Which is to say, “we work around the race condition by being slow
and shitty.”
socat STDIO UNIX-SENDTO:"$NOTIFY_SOCKET" <<<READY=1 || true
But, I don’t like that. I’d rather write my WMII configuration to the
world as I wish it existed, and have workarounds encapsulated elsewhere;
“If
you have to cut corners in your project, do it inside the
implementation, and wrap a very good interface around it.”. So, I
wrote a systemd-notify
compatible function
that ultimately calls socat
:
##
# Just like systemd-notify(1), but slower, which is a shitty
# workaround for a race condition in systemd.
##
systemd-notify() {
local args
args="$(getopt -n systemd-notify -o h -l help,version,ready,pid::,status:,booted -- "$@")"
ret=$?; [[ $ret == 0 ]] || return $ret
eval set -- "$args"
local arg_ready=false
local arg_pid=0
local arg_status=
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help) command systemd-notify --help; return $?;;
--version) command systemd-notify --version; return $?;;
--ready) arg_ready=true; shift 1;;
--pid) arg_pid=${2:-$$}; shift 2;;
--status) arg_status=$2; shift 2;;
--booted) command systemd-notify --booted; return $?;;
--) shift 1; break;;
esac
done
local our_env=()
if $arg_ready; then
our_env+=("READY=1")
fi
if [[ -n "$arg_status" ]]; then
our_env+=("STATUS=$arg_status")
fi
if [[ "$arg_pid" -gt 0 ]]; then
our_env+=("MAINPID=$arg_pid")
fi
our_env+=("$@")
local n
printf -v n '%s\n' "${our_env[@]}"
socat STDIO UNIX-SENDTO:"$NOTIFY_SOCKET" <<<"$n"
}
So, one day when the systemd bugs have been fixed (and presumably the
Linux kernel supports passing the cgroup of a process as part of its
credentials), I can remove that from workarounds.sh
, and
not have to touch anything else in my WMII configuration (I do use
systemd-notify
in a couple of other, non-essential, places
too; this wasn’t to avoid having to change just 1 line).
So, now that wmii@.service
properly has
Type=notify
, I can just stick
After=wmii@.service
into my lxpanel@.service
,
right? Wrong! Well, I could, but my lxpanel
service has nothing to do with WMII; why should I couple them? Instead,
I create wm-running@.target
that can be used as a synchronization point:
# wmii@.service
Before=wm-running@%i.target
# lxpanel@.service
After=X11@%i.target wm-running@%i.target
Requires=wm-running@%i.target
Finally, I have my desktop started and running. Now, I’d like for
programs that aren’t part of the window manager to not dump their stdout
and stderr into WMII’s part of the journal, like to have a record of
which graphical programs crashed, and like to have a prettier
cgroup/process graph. So, I use systemd-run
to run external
programs from the window manager:
runcmd() (
...
unset NOTIFY_SOCKET # systemd
...
exec 8>&- # xinit/systemd handshake
exec systemd-run --user --scope -- sh -c "$*"
)
I run them as a scope instead of a service so that they inherit
environment variables, and don’t have to mess with getting
DISPLAY
or XAUTHORITY
into their units (as I
don’t want to make them global variables in my systemd user
session).
I’d like to get lxpanel
to also use
systemd-run
when launching programs, but it’s a low
priority because I don’t really actually use lxpanel
to
launch programs, I just have the menu there to make sure that I didn’t
break the icons for programs that I package (I did that once back when I
was Parabola’s packager for Iceweasel and IceCat).
And that’s how I use systemd with X11.