dotfiles
Boot and storage

shutdown

Edit on GitHub

Module: modules/nixos/core/shutdown.nix

How long systemd waits for a unit to stop before the SIGKILL.

The symptom, and the number that gave it away

"A stop job is running…" and the shutdown taking a minute and a half. Measured on 09/08/2026 over the last 10 boots: 90.3 / 90.4 / 90.5 / 90.6 s. Far too round to be real work. That is not the system doing something, it is a TIMEOUT firing, and the timeout was systemd's default: 90 s.

A single culprit, on the user's side

VS Code. It runs in a session scope (app-code-<pid>.scope, created by GLib when launching the .desktop) and it does not answer the SIGTERM. Every shutdown closed the same way:

app-code-*.scope: Stopping timed out. Killing.

Before VS Code, the same pattern showed up with Chromium. It is Electron behavior, not this machine's. EVERYTHING else (docker, jellyfin, the network, the unmounts, swap) stops in under 2 s, so there was nothing to optimize, only waiting on a process that was never going to answer.

What the change does not do

It does not start killing anything that used to die a natural death. The SIGKILL already happened, 90 s later. Whoever saves state on SIGTERM (pipewire, dropbox, rclone, all the daemons) takes under 1 s and keeps saving; whoever ignores the signal merely stops charging us the wait.

Why 5 s on the user side and 30 s on the system side

The two sides have different tenants, and a single value would serve both badly.

  • User: desktop apps. Whoever was going to save has saved; what is left is a hung Electron. 5 s is generous slack for an honest SIGTERM.
  • System: docker compose down sits in the ExecStop of duo and grad-radar, and down gives EACH container 10 s of grace before killing it. A tight ceiling here would SIGKILL those stacks' Postgres in the middle of the down. It does not corrupt anything, but it comes back doing WAL recovery on the next boot, and the price shows up far from the cause. 30 s covers the down with room to spare and is still 3x faster than the default.

It is a default, not a ceiling

A unit with its OWN TimeoutStopSec ignores everything here. Today that is qbittorrent (30 min), jellyfin (15 s), caddy (5 s), hyprpolkitagent (5 s) and user@.service (2 min, from upstream). If the shutdown ever gets slow again, look first for whoever declared their own value:

systemctl show <unit> -p TimeoutStopUSec

The two sides have different APIs, and the asymmetry is a real trap

systemd.extraConfig WAS REMOVED (26.05 wants systemd.settings.Manager, freeform), but systemd.user.extraConfig is still the only form on the user side, because systemd.user.settings does NOT exist (checked in the options).

The way that fails is the worst possible: writing to the removed option, the nix eval of the generated system.conf passes and comes out WITHOUT the line. Nothing warns you. So the validation is not "did it build?", it is READING the generated file:

nix eval --raw '.#nixosConfigurations.ex-b560m-v5.config.environment.etc."systemd/system.conf".text'

To check the effect without a stopwatch, the journal's two stamps:

journalctl -b -1 -o short-precise | grep -E "Stopping User Manager|Journal stopped"

Measured again: 17 s down to 7 s (02/11/2026)

With the splash, the clock runs from Show Plymouth Reboot Screen (or Power Off) to Journal stopped. Six shutdowns from 27/10 to 02/11 took 16.6 to 18.1 s, and 10 s of that was one container:

grad-radar-backend-1: Container failed to exit within 10s of signal 15 - using the force

Its compose command was sh -c "uv sync && uvicorn ...", so PID 1 was sh, which never forwards SIGTERM, and down waited out Docker's 10 s grace every time. init: true would not have helped: tini forwards to its child, which is still sh. The fix is exec uvicorn in the grad-radar repo (684b1fd): compose stop backend went from 10.2 s and exit 137 to 0.55 s and exit 0.

The three shutdowns after it with the stack running took 6.8, 7.0 and 7.7 s, no forced kill. What is left is the Docker daemon waiting on Home Assistant, which saves its state on the way out (about 5 s); no timeout was changed for it. To repeat the measurement on the last boot:

journalctl -b -1 -o short-precise | grep -E "Plymouth (Reboot|Power Off) Screen|using the force|Journal stopped"

hyprshutdown: looked at, not adopted (22/10/2026)

hyprshutdown 0.1.1 (in the pinned nixpkgs and in unstable) opens a dialog, asks every window to close, then exits Hyprland; powering off is a --post-cmd that runs AFTER Hyprland is gone. Against what runs here:

  • The autologin races it. LightDM logs this session back in as soon as Hyprland exits, so a --post-cmd systemctl poweroff would compete with a fresh session starting. The current path asks logind first, and logind owns the order of the stop.
  • It adds nothing to reboot and poweroff. systemd already sends SIGTERM to every app and waits up to the 5 s above; an app that saves on a close request but not on SIGTERM is the only gain, and none is known here.
  • Log out is where it could help: a graceful close instead of loginctl terminate-user. That needs the autologin race above tested first. Dropped on 02/11/2026: no app here is known to lose anything on terminate-user.

So the session's actions stay on systemd and logind, and the NixOS Center's confirmation (nixos-center) asks first.