Running Emacs in systemd's session.slice

November 12, 2023 – Leon Schuermann – #emacs #nix

I use Emacs and EXWM as my window manager. For this setup, running an Emacs daemon as a systemd-user unit allows me to attach multiple clients to this process (for instance, to work in the same session through an SSH connection on my iPad), and have the daemon survive restarts of my graphical session. However, all subprocesses started from within Emacs—which are virtually all applications & shells on my system—are then tracked within the same systemd scope. This means that a single application consuming excessive amounts of memory can bring down my entire user session (looking at you, Firefox). This post documents how you can move this Emacs daemon from the systemd app.slice into the more appropriate session.slice, and run applications & shells from within Emacs in their own scopes in app.slice.

Table of Contents

1. Emacs as a systemd user unit

To run an Emacs daemon as a systemd user-unit on a NixOS system, it's as easy setting the following configuration option:

services.emacs.enable = true;

This will generate a systemd user-unit globally, for all users:

> systemctl --user status emacs.service
● emacs.service - Emacs: the extensible, self-documenting text editor
     Loaded: loaded (/etc/systemd/user/emacs.service; enabled; preset: enabled)
     Active: active (running) since Sun 2023-11-12 12:43:15 EST; 14min ago

On non-NixOS systems you can manually create a systemd user-unit that simply starts emacs --daemon with Type=forking to achieve the same result.

2. Systemd slices, scopes and services

However, when we use this daemon for a while and spawn some subprocesses, we can observe a systemd supervision tree such as the following:

> systemctl --user status
● caesium
    State: running
    Units: 356 loaded (incl. loaded aliases)
     Jobs: 0 queued
   Failed: 0 units
    Since: Fri 2023-11-03 13:09:42 EDT; 1 week 2 days ago
  systemd: 253.6
   CGroup: /user.slice/user-1000.slice/user@1000.service
           ├─app.slice
           │ ├─emacs.service
           │ │ ├─ 527741 /run/current-system/sw/bin/fish
           │ │ ├─ 599026 bash /tmp/nix-shell-599026-0/rc
           │ │ ├─ 601605 fish
           │ │ ├─ 719460 /run/current-system/sw/bin/fish
           │ │ ├─1313249 bash /tmp/nix-shell-1313249-0/rc
           │ │ ├─1480430 /nix/store/85vasp6dpm5jldv45sqggxijcp910px6-erlang-25.3.2/lib/erlang/erts-13.2.2/bin/beam.smp>
           │ │ ├─1480452 erl_child_setup 1024
           │ │ ├─2116444 /nix/store/5vx99s8cjzv8hcirly8g06alhjz0zaba-emacs-28.2/bin/emacs --daemon
           │ │ ├─2116678 /nix/store/sbr06rvajdmqdxhdf5rg9z3r87fifral-emacs-packages-deps/share/emacs/site-lisp/elpa/pd>
           │ │ ├─3378466 /run/current-system/sw/bin/fish
           │ │ ├─3408377 ssh root@10.237.4.2
           │ │ ├─3408502 "ssh: /home/leons/.ssh/S.root@10.237.4.2:22 [mux]"
           │ │ └─3466757 /run/current-system/sw/bin/fish

We can see that the emacs.service is managed as part of the app.slice systemd slice. Also, the emacs.service unit tracks a bunch of subprocesses, such as shells or programs I've started from within Emacs. Let's check the systemd manual on slice units:

A slice unit is a concept for hierarchically managing resources of a group of processes. This management is performed by creating a node in the Linux Control Group (cgroup) tree. Units that manage processes (primarily scope and service units) may be assigned to a specific slice. For each slice, certain resource limits may be set that apply to all processes of all units contained in that slice.

So essentially, a slice is a collection of scope and service managed under a given cgroup. Systemd user sessions create a few such slices for us, namely:

  • session.slice: Contains only processes essential to run the user’s graphical session
  • app.slice: Contains all normal applications that the user is running
  • background.slice: Useful for low-priority background tasks

And the manpage of systemd-oomd tells us exactly why the above situation on my system is not great:

Be aware that if you intend to enable monitoring and actions on user.slice, user-$UID.slice, or their ancestor cgroups, it is highly recommended that your programs be managed by the systemd user manager to prevent running too many processes under the same session scope (and thus avoid a situation where memory intensive tasks trigger systemd-oomd to kill everything under the cgroup).

Thus, to conform with systemd's expectations, and to ultimately avoid having a single memory-hungry app kill our entire user-session, we should

  • move the emacs.service into the session.slice, as it is very much essential to running our user session, and
  • run subprocesses started from within Emacs in app.slice, ideally in their own scope, such that they are assigned their own cgroup and that systemd-oomd can apply more fine-grained out-of-memory policies.

3. Moving emacs.service into session.slice

To run emacs.service as part of the session.slice supervision tree, we simply need to set the following option in the service's [Unit] section:

Slice=session.slice

In NixOS, we can achieve this by setting this option in the generated systemd user-unit configuration:

systemd.user.services.emacs.serviceConfig = {
  Slice = "session.slice";
};

Reloading the systemd user daemon (systemctl --user daemon-reload) and restarting the emacs.service should now spawn it under the session.slice, as intended:

> systemctl --user status
● silicon
   CGroup: /user.slice/user-1000.slice/user@1000.service
           ├─app.slice
           ├─session.slice
           │ ├─emacs.service
           │ │ ├─1122918 /nix/store/p7pp0ix0wr7gaxjdz7r8bpcbx2cdfms5-emacs-28.2/bin/emacs --daemon
	   [...]

However, all subprocesses launched from within Emacs are still attached to emacs.service, and thus now managed in the session.slice.

4. Running Emacs subprocesses under app.slice scopes

In general, we can instruct systemd to run a process under a new scope using the systemd-run command:

> systemd-run --user --scope -- sleep 60
Running scope as unit: run-r243444bafda04cc08f72aa350fa7175c.scope

This will create a new anonymous scope and launch the supplied command within it:

> systemctl --user status
● silicon
   CGroup: /user.slice/user-1000.slice/user@1000.service
           ├─app.slice
           │ ├─run-r243444bafda04cc08f72aa350fa7175c.scope
           │ │ └─1350382 /run/current-system/sw/bin/sleep 60
	   [...]

While systemd-run --user --scope appears to default to creating scopes in app.slice, we can make that explicit by passing --slice=app.slice. Furthermore, to suppress the Running scope as unit message, we can pass -q (quiet). Now, starting subprocesses in such scopes should be as easy as prefixing their commands with the above systemd-run incantation.

Unfortunately, I am not aware of a method to apply this to all subprocesses launched by Emacs. Given that I almost exclusively work in EXWM and vterm-mode, spawning GUI applications and vterm shells in their own scopes is sufficient for me. However, Emacs may spawn a plethora of other (potentially memory-intensive) applications such as through Magit, etc. If you know of a more general approach to run subprocesses in their own scopes, please let me know!

4.1. Running EXWM applications in their own scopes

EXWM defines the s-& key-binding by default to spawn an application without creating a buffer for its output. We can simply override the definition of this key-binding to start the passed commands through systemd-run instead:

(exwm-input-set-key
 (kbd "s-&")
 (lambda (command)
   (interactive (list (read-shell-command "$ ")))
   (start-process-shell-command
    command
    nil
    (format "systemd-run -q --user --slice=app.slice --scope -- %s" command))))

4.2. Running vterm shell processes in systemd scopes

vterm-mode supports the vterm-shell customization, defaulting to the contents of the shell-file-name variable. We can simply customize this variable to prefix the shell command with systemd-run:

(use-package vterm
  :ensure t)
(custom-set-variables
 '(vterm-shell (format "systemd-run -q --user --slice=app.slice --scope -- %s" shell-file-name)))