feat(stability): default-on memory-pressure protection (earlyoom)
Some checks failed
Check / eval (push) Has been cancelled

modules/nixos/oom.nix — running out of memory now kills the offending
process (with a desktop notification via systembus-notify) instead of
freezing the desktop for minutes until the kernel OOM killer fires.

earlyoom over systemd-oomd, deliberately: oomd kills whole cgroups,
and a Hyprland session runs as ONE scope (nothing here spawns per-app
systemd scopes, unlike GNOME) — under pressure oomd would take out the
entire desktop to save it. earlyoom kills the single largest process
before the thrash point. nixpkgs default-enables oomd in an inert
state (no slices monitored); it is disabled outright so there is one
owner. Session plumbing is --avoid-listed (unanchored — NixOS wrappers
rename comm to .foo-wrapped); no --prefer tuning, largest-RSS
selection already finds the hog. All mkDefault; opt out with
services.earlyoom.enable = false (README note added).

Verified: V0 (flake check) + V2 — new checks.oom-protection
runNixOSTest, executed: a chunked allocator (686 MB peak, 1 GB VM) is
SIGTERM'd by earlyoom in 0.1 s, a bystander unit survives (the
process-level granularity the module exists for), and systemd-oomd is
asserted inactive. 25 s runtime.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-07-04 09:45:21 +01:00
parent 938753273d
commit 5ea4f0c9ac
7 changed files with 133 additions and 17 deletions

View File

@@ -207,6 +207,53 @@
'';
};
# Memory-pressure protection (oom.nix): earlyoom must kill a
# runaway allocator BEFORE the kernel thrash point — and must not
# take anything else with it. A bystander unit survives the kill,
# proving the process-level granularity that motivated earlyoom
# over cgroup-level systemd-oomd (which would kill the whole
# Hyprland session scope). Imports only oom.nix so the VM stays
# minimal.
oom-protection = pkgs.testers.runNixOSTest {
name = "nomarchy-oom-protection";
nodes.machine = { ... }: {
imports = [ ./modules/nixos/oom.nix ];
environment.systemPackages = [ pkgs.python3 ];
virtualisation.memorySize = 1024;
};
testScript = ''
machine.wait_for_unit("earlyoom.service")
# One owner: oomd (nixpkgs default-on, inert) is disabled.
machine.fail("systemctl is-active --quiet systemd-oomd.service")
# The session avoid-list made it into the daemon invocation.
machine.succeed("systemctl cat earlyoom.service | grep -q -- --avoid")
# A bystander that must survive (stand-in for the session).
machine.succeed("systemd-run --unit=bystander sleep infinity")
# The hog: allocate in 20 MB chunks with a short sleep, so
# earlyoom's poll wins the race against the kernel OOM killer.
machine.succeed(
"systemd-run --unit=hog python3 -c 'import time; "
"exec(\"a=[]\\nwhile True:\\n a.append(bytearray(20*1024*1024)); time.sleep(0.05)\")'"
)
# earlyoom (not the kernel) pulled the trigger
machine.wait_until_succeeds(
"journalctl -u earlyoom.service | grep -Eiq 'sending SIG(TERM|KILL)'",
timeout=120,
)
# the hog is gone
machine.wait_until_succeeds(
'test "$(systemctl show -p ActiveState --value hog.service)" != "active"',
timeout=60,
)
# and the bystander is untouched.
machine.succeed("systemctl is-active --quiet bystander.service")
'';
};
# The distroId question (roadmap "Distro branding"): set
# distroId = "nomarchy" so /etc/os-release is honest (ID=nomarchy,
# ID_LIKE=nixos). The risk is switch-to-configuration's "is this