Compare commits

..
6 Commits
Author SHA1 Message Date
ahmza f8552bc7bb run hypridle and hyprsunset as graphical-session user units
both were installed but never launched. they ship their own units, but in
$out/share/systemd/user - not $out/lib/systemd/user - and that distinction
is why they need redeclaring rather than merely enabling:

  * systemd.packages would collect nothing, since nixpkgs' generateUnits
    globs only $pkg/etc/systemd/user and $pkg/lib/systemd/user.
  * the units are nonetheless visible to the user manager, via
    environment.pathsToLink -> /run/current-system/sw/share/systemd/user,
    which is on the search path because XDG_DATA_DIRS ends with
    /run/current-system/sw/share. but visible is not enabled - nothing
    creates the WantedBy symlink, so they sit unstarted.
  * wantedBy alone would not help either: with no other keys nixos
    synthesises a replacement unit with an empty [Service] section, and it
    shadows upstream's because /etc/systemd/user takes precedence over
    $XDG_DATA_DIRS.

binding them to graphical-session.target, which uwsm manages properly,
means they start and stop with the session instead of leaking across
logins - same reasoning as udiskie, so that moves into the same attrset.

close to upstream's definitions but not identical: hyprsunset's
Requires=graphical-session.target is dropped, because PartOf already
propagates the stop while Requires would additionally make starting
hyprsunset by hand pull up the whole session target. the Slice=session.slice
asymmetry is upstream's own - hyprsunset sets it, hypridle does not - and is
noted inline so it does not get "fixed" later.

hypridle gets an explicit path because its config shells out to hyprlock,
pidof, brightnessctl, hyprctl, loginctl, systemctl and notify-send. that
config lives in the dotfiles repo, so this list can silently drift out of
sync with it: a binary used there but missing here fails as a bare "command
not found" inside hypridle, and Restart=on-failure will not catch it because
hypridle itself stays up. flagged in a comment at both ends.
2026-08-06 17:09:30 +01:00
ahmza 29707ba0f6 add a 40gib swapfile and wire up hibernation on shadow
swap exists for hibernation, not for memory pressure - 31gib of ram on a
desktop rarely needs paging. the hibernation image must fit in swap so the
floor is "as big as ram"; 40gib leaves headroom. the 3090's vram does not
land here: with NVreg_PreserveVideoMemoryAllocations=1 the driver spools it
to NVreg_TemporaryFilePath, which nixpkgs leaves unset and so defaults to
/tmp. that is only safe while /tmp is on the real root - boot.tmp.useTmpfs
would route a full vram dump into ram and break hibernation outright.

resuming needs both boot.resumeDevice and resume_offset. the device half is
mandatory rather than optional here because boot.initrd.systemd.enable is
true, and under systemd initrd the resume= parameter is emitted only from
boot.resumeDevice - the legacy stage-1 script that scans swapDevices is not
in play. without it hibernate succeeds and the machine then cold-boots,
silently discarding the session.

resume_offset is the physical block offset of the swapfile's first extent,
since the kernel addresses the file by raw block. that value is hand-copied,
and a stale one fails silently: no swsusp signature at the given block means
`PM: Image not found (code -22)` and a cold boot, with no error at hibernate
time. so an activation check warns on drift. it is a detector, not a gate -
nixpkgs only recreates the swapfile when size changes, so on the rebuild
that resizes it the check still sees the old file and fires on the next one.
it warns rather than fails, and expects to read nothing on a fresh install
where mkswap-var-lib-swapfile.service has not run yet.

sleeping via suspend-then-hibernate (hypridle invokes it): s3 first so a
quick return is instant, then a real suspend-to-disk once the machine has
clearly been abandoned. deliberately not hybrid-sleep, which writes the disk
image up front on every single sleep - measured here at ~59s of filesystem
sync plus an 11gib write before the screens even went off, and because an s3
wake invalidates the image the next boot always reported `Image not found`.
full hibernation price paid every time, redeemed only on a power cut.
suspend-then-hibernate defers that write until it is worth doing, and unlike
hybrid-sleep it is a mode nvidia explicitly supports.

HibernateDelaySec is explicit because systemd otherwise derives the handoff
from battery discharge estimation, and this desktop has no system battery -
the sole /sys/class/power_supply entry is a logitech mouse reporting
scope=Device, which is ignored for that purpose.

also corrects a comment: a sparse swapfile does not silently corrupt.
swapon refuses files with holes outright ("skipping - it appears to have
holes"), so the failure is loud and the system simply ends up with no swap.
genuine silent corruption is the copy-on-write case, which is why btrfs
needs a NOCOW-from-creation file.
2026-08-06 17:09:14 +01:00
ahmza 67bafd4f73 switch to open nvidia modules and make sleep transitions actually work
three related changes, all needed together to get this card through a
sleep cycle with its framebuffer intact.

open = true. mandatory to set explicitly on driver >= 560, where the
option defaults to null and nixpkgs asserts against null. true because
every card this module targets is turing or later, which is what upstream
recommends open modules for. mkDefault so a future pre-turing host can
override at host level - and note that without it, importing both
gpuNvidia and gpuNvidiaPrime would be an outright merge conflict rather
than something resolvable. this selects between two real builds of the
same release, nvidia-open vs the proprietary kernel modules; it has no
effect on driver version. confirmed after reboot:
/proc/driver/nvidia/version reports the open module.

kernelSuspendNotifier = false. nixpkgs defaults this to
`useOpenModules && version >= 595`, which is silently true here, and that
path wedges the gpu on resume - nv_pm_notifier warns, the card never
comes back, and an unrecoverable gsp rpc storm follows. pinning it false
selects the nvidia-{suspend,hibernate,resume}.service path instead.

installing upstream's system-sleep hook, because those units are wired
only to systemd-{suspend,hibernate}.service, while suspend-then-hibernate
runs as a single systemd-suspend-then-hibernate.service that performs
both transitions internally. no nvidia unit fires for it at all - the
units cannot express it, which is exactly why nvidia ships the hook.
nixpkgs sets systemd.packages from the driver package, but that only
collects lib/systemd/system/, never lib/systemd/system-sleep/, so the
hook is silently discarded.

it cannot be installed verbatim, for two separate reasons.

first, past its suspend-then-hibernate branch it ends in an
unconditional `case "$1" in post) nvidia-sleep.sh resume`. for plain
suspend that duplicates nvidia-resume.service, and destructively:
nvidia-sleep.sh's RestoreVT reads /run/nvidia-sleep/Xorg.vt_number,
deletes it, then chvt's back. chvt lives in pkgs.kbd, which nixpkgs puts
on nvidia-resume.service's path but which is absent from the stripped
path systemd-sleep gives its hooks. the hook would consume the vt file,
fail to chvt, and the real unit would then find nothing to restore.

second, its suspend-then-hibernate dispatch handles post:*,
pre:hibernate and pre:suspend-after-failed-hibernate - but there is no
pre:suspend, which is the *first* leg of suspend-then-hibernate. it
matched nothing, fell through, and the driver never got its vram-spool
handshake, so with NVreg_PreserveVideoMemoryAllocations=1 the gpu refused
to suspend and the whole operation aborted:

  nvidia 0000:2d:00.0: PM: failed to suspend async: error -5
  PM: Some devices failed to suspend, or early wake event detected
  systemd-sleep: Failed to put system to sleep. System resumed again:
    Input/output error

so the wrapper gates out everything except suspend-then-hibernate, fills
in pre:suspend the same way upstream fills in pre:hibernate, and hands
the rest to upstream unchanged. worth reporting upstream - as shipped
that hook cannot work for suspend-then-hibernate whenever
PreserveVideoMemoryAllocations is on.

verified on hardware: plain suspend and plain hibernate both clean with
no chvt failure and the vt file consumed by the right unit, and a full
suspend-then-hibernate cycle now runs all four transitions - suspend
entry (deep), rtc wake, hibernation entry, hibernation exit.
2026-08-06 17:08:55 +01:00
ahmza b7e6418fa6 chainload the windows boot manager from grub on shadow
windows sits on the 476g nvme - the disk with three partitions: a 16m
"microsoft reserved" msr with no filesystem, the 476g ntfs c: volume, and
a 832m winre. it has no efi system partition of its own, because it was
installed against the esp that nixos now owns (fs-uuid 5873-AD05). both
oses therefore boot from one shared esp; there is no separate windows
boot drive.

the nixos install reformatted that esp, which wiped
EFI/Microsoft/Boot/{bootmgfw.efi,BCD} and left windows unbootable. that
has since been repaired from winre, so the entry is live. the bcdboot
command is kept in the comment for the next time the esp is reformatted -
it has to run from windows media or winre because the bcd store cannot be
generated from linux, and copying bootmgfw.efi alone fails 0xc000000f.

chainloaded explicitly rather than via grub.useOSProber: deterministic,
pulls in no extra tooling, and avoids probing every disk on each
bootloader install. the -f guard makes the entry self-disabling, so a
future esp reformat degrades to a missing menu item rather than one that
boots into an error. because grub evaluates it at boot rather than at nix
eval time, repairing windows needed no rebuild.

searching by fs-uuid rather than (hdX,gptN) is deliberate: nvme
enumeration is not stable on this board, and /boot has been seen as both
nvme0n1p1 and nvme1n1p1. the -n "$esp" test is a separate nested if
rather than `[ -n "$esp" -a -f ... ]` because grub's test builtin
evaluates both operands of -a instead of short-circuiting, so the
combined form would still expand the path test against an unset device.
2026-08-06 17:08:30 +01:00
ahmza fc1772e5db pin the bootloader explicitly and bound the grub menu to 10 generations
grub.enable defaults to true, so adding a boot.loader block silently
switched this host from the systemd-boot it was installed with over to
grub. nixos removes neither the old bootloader's files nor its nvram
entry, so both lived on the esp and the firmware kept booting a frozen
systemd-boot menu. pinning both options means a conflicting definition
elsewhere now fails loudly instead of quietly winning. note the pins
delete nothing - stale EFI/systemd/, loader/ and the leftover nvram entry
still have to be cleared by hand. done on shadow, not verified on t480.

configurationLimit bounds esp usage for real, not just the menu:
install-grub.pl unlinks any $boot/kernels/* that no kept generation
references. sizing measured on shadow rather than assumed - 10 kept
generations referenced 1 bzImage and 3 initrds for 114mib total, about
11mib per generation, because generations share aggressively. the
worst case is higher, around 47mib each, since nixpkgs embeds
/etc/modprobe.d/nixos.conf inside the initrd and so every kernel module
parameter edit mints a fresh one. 10 therefore costs ~115mib typically
and ~470mib worst case against a 1gib esp that also carries the grub
modules, the catppuccin theme, and windows' own boot files.

keeping it conservative matters because overflow fails as `copy ... or
die` in install-grub.pl, which runs *after* the system profile has
already been switched - the result is an activated but unbootable
generation. t480 reaches this module too and its esp may be smaller.
2026-08-06 17:08:15 +01:00
ahmza 42c805f430 untrack the build result symlink and harden .gitignore
result is a symlink into /nix/store that nix build rewrites on every
invocation, so tracking it churns the history and pins a store path that
means nothing on any other machine. it was committed pointing at a t480
system while sitting on shadow.

the two git-secret lines are what `git secret init` writes. worth being
explicit about what they do and do not cover: `!*.secret` un-ignores the
*encrypted* blobs only. nothing here protects the plaintext originals -
that relies entirely on `git secret add` appending each plaintext path to
this file, so always run it from the repo root rather than hand-editing
.gitsecret/paths/mapping.cfg.

.gitsecret/keys is git-secret's gpg homedir. pubring.kbx is meant to be
committed, but a stray `gpg --homedir .gitsecret/keys --import` of a
private key would drop private-keys-v1.d/ in there, so those paths are
ignored up front rather than after the fact.
2026-08-06 17:07:59 +01:00
7 changed files with 489 additions and 21 deletions
+23
View File
@@ -1 +1,24 @@
.session .session
# `nix build` / `nixos-rebuild build` output symlink. Points into /nix/store and
# churns on every build, so it must not be tracked.
result
result-*
# git-secret. `git secret init` writes the random_seed and the `!*.secret`
# negation; the rest is hardening.
#
# WARNING: `!*.secret` only un-ignores the *encrypted* blobs. It does nothing to
# protect the plaintext originals -- that relies entirely on `git secret add`
# appending each plaintext path below. Always run `git secret add <file>` from
# the repo root (never hand-edit .gitsecret/paths/mapping.cfg) and verify with
# `git status` before committing.
.gitsecret/keys/random_seed
# .gitsecret/keys is git-secret's GPG homedir. pubring.kbx is meant to be
# committed; private key material and sockets never are.
.gitsecret/keys/private-keys-v1.d/
.gitsecret/keys/openpgp-revocs.d/
.gitsecret/keys/*.gpg~
.gitsecret/keys/.#*
.gitsecret/keys/S.*
!*.secret
+79 -5
View File
@@ -37,11 +37,33 @@
# are declared in the host's disks.nix instead. # are declared in the host's disks.nix instead.
services.udisks2.enable = true; services.udisks2.enable = true;
# Run udiskie as a user unit rather than from the hypr autostart — it was # Session daemons run as systemd user units bound to graphical-session.target
# previously only installed, never actually launched. Binding it to # (which UWSM manages properly) rather than from the hypr autostart, so they
# graphical-session.target (which UWSM manages properly) means it starts and # start and stop with the session instead of leaking across logins.
# stops with the session instead of leaking across logins. #
systemd.user.services.udiskie = { # hypridle and hyprsunset both ship their own unit, but in
# $out/share/systemd/user — NOT $out/lib/systemd/user. That distinction is
# why they need redeclaring rather than just enabling:
#
# * `systemd.packages = [pkgs.hypridle]` would collect nothing. nixpkgs'
# generateUnits globs only $pkg/etc/systemd/user and $pkg/lib/systemd/user.
# * The units are nonetheless *visible* to the user manager, via
# environment.pathsToLink -> /run/current-system/sw/share/systemd/user,
# which is on the search path because XDG_DATA_DIRS ends with
# /run/current-system/sw/share. But visible is not enabled: nothing
# creates the WantedBy symlink, so they sit unstarted.
# * `systemd.user.services.<n>.wantedBy` alone would not help either — with
# no other keys NixOS synthesises a *replacement* unit with an empty
# [Service] section, shadowing upstream's via /etc/systemd/user (which
# takes precedence over $XDG_DATA_DIRS).
#
# So the full definition below is deliberate. It is close to upstream's but
# not identical: hyprsunset's `Requires=graphical-session.target` is dropped
# on purpose (PartOf already propagates the stop, while Requires would also
# make starting hyprsunset by hand pull up the whole session target), and
# hypridle gains an explicit `path` because its config shells out.
systemd.user.services = {
udiskie = {
description = "udiskie removable media automounter"; description = "udiskie removable media automounter";
partOf = ["graphical-session.target"]; partOf = ["graphical-session.target"];
after = ["graphical-session.target"]; after = ["graphical-session.target"];
@@ -54,6 +76,58 @@
}; };
}; };
hypridle = {
description = "Hyprland idle daemon";
documentation = ["https://wiki.hyprland.org/Hypr-Ecosystem/hypridle"];
partOf = ["graphical-session.target"];
after = ["graphical-session.target"];
wantedBy = ["graphical-session.target"];
# ~/.config/hypr/hypridle.conf calls out to these: hyprlock (lock_cmd),
# pidof (the `pidof hyprlock ||` guard), brightnessctl (backlight
# listener), hyprctl (dpms), loginctl + systemctl (sleep/lock),
# notify-send (unlock_cmd).
#
# MAINTENANCE HAZARD: that config lives in a *different* repo (the stow'd
# `hypr` package in the dotfiles repo), so this list can silently drift
# out of sync with it. A binary used there but missing here fails as a
# bare "command not found" inside hypridle, and Restart=on-failure will
# NOT catch it because hypridle itself stays up — the idle action just
# quietly stops working. Add any new binary to both places.
path = with pkgs; [
hyprlock
hyprland
brightnessctl
libnotify
procps
systemd
];
unitConfig.ConditionEnvironment = "WAYLAND_DISPLAY";
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.hypridle}/bin/hypridle";
Restart = "on-failure";
};
};
hyprsunset = {
description = "Hyprland blue-light filter";
documentation = ["https://wiki.hyprland.org/Hypr-Ecosystem/hyprsunset"];
partOf = ["graphical-session.target"];
after = ["graphical-session.target"];
wantedBy = ["graphical-session.target"];
unitConfig.ConditionEnvironment = "WAYLAND_DISPLAY";
serviceConfig = {
Type = "simple";
ExecStart = "${pkgs.hyprsunset}/bin/hyprsunset";
Restart = "on-failure";
# Intentionally only on hyprsunset, not hypridle/udiskie: upstream's
# hyprsunset.service sets Slice=session.slice and upstream's
# hypridle.service does not. Not an oversight — don't "fix" it.
Slice = "session.slice";
};
};
};
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
# hypr* ecosystem (keybinds / hypridle / autostart) # hypr* ecosystem (keybinds / hypridle / autostart)
hyprlock hyprlock
+97 -2
View File
@@ -6,7 +6,7 @@
# (`nix run .#sync-hardware`) safe: re-running it can never clobber these # (`nix run .#sync-hardware`) safe: re-running it can never clobber these
# definitions, nor produce duplicates for whichever data drives happened to # definitions, nor produce duplicates for whichever data drives happened to
# be mounted at the time it ran. # be mounted at the time it ran.
flake.nixosModules.shadowDisks = {...}: let flake.nixosModules.shadowDisks = {pkgs, ...}: let
# Secondary data drives, addressed by filesystem label. # Secondary data drives, addressed by filesystem label.
# #
# nofail on all of them: it stops a missing or dying disk from becoming a # nofail on all of them: it stops a missing or dying disk from becoming a
@@ -74,6 +74,101 @@
"/mnt/games" = eagerDrive "GAMES"; "/mnt/games" = eagerDrive "GAMES";
}; };
swapDevices = []; # Swap exists for hibernation, not for memory pressure — 31GiB of RAM on a
# desktop rarely needs paging. The hibernation image must fit in swap, so the
# floor is "as big as RAM"; 40GiB leaves headroom above that.
#
# NVIDIA video memory does NOT land here: with
# NVreg_PreserveVideoMemoryAllocations=1 (set by
# hardware.nvidia.powerManagement.enable) the driver spools the 3090's VRAM
# to NVreg_TemporaryFilePath, which is unset and so defaults to /tmp. That is
# only safe because /tmp is on this ext4 root — if /tmp ever becomes a tmpfs
# (boot.tmp.useTmpfs) that VRAM dump goes into RAM and hibernation breaks.
#
# On non-btrfs filesystems NixOS creates this with `dd if=/dev/zero`, so the
# file is fully allocated rather than sparse. That matters because the kernel
# writes swap through the block layer using the extent map swapon built,
# bypassing the filesystem — so the mapping has to be stable and complete.
#
# The failure is loud, not silent, though: swapon refuses a file with holes
# outright ("skipping - it appears to have holes"), so a sparse file would
# simply leave the system with no swap rather than corrupt anything. Genuine
# silent corruption is the copy-on-write case, which is why btrfs needs
# chattr +C and a NOCOW-from-creation file.
swapDevices = [
{
device = "/var/lib/swapfile";
size = 40 * 1024; # MiB
}
];
# Resuming from hibernation needs the kernel to be told where the image is,
# and for a swap *file* that means both halves below.
#
# boot.initrd.systemd.enable is true on this host, which makes this
# mandatory rather than optional: under systemd initrd the `resume=` kernel
# param is emitted ONLY from boot.resumeDevice. The legacy stage-1 script
# that scans swapDevices to find a resume device is not in play. Without
# this, hibernate succeeds and the machine then cold-boots, silently
# discarding the session.
#
# resume_offset is the physical block offset of the swapfile's first extent,
# because the kernel addresses the file by raw block, not by path. Recompute
# it with the following if the swapfile is ever recreated, resized, or moved
# — a stale value resumes from the wrong blocks:
#
# sudo filefrag -v /var/lib/swapfile | awk '$1=="0:" {gsub(/\.\./,"",$4); print $4}'
#
# filefrag reports in 4096-byte filesystem blocks and the kernel wants
# PAGE_SIZE units; on x86_64 both are 4096, so the number transfers directly.
boot.resumeDevice = "/dev/disk/by-uuid/deff63fc-1738-4cfc-a79c-06e58c78471a";
boot.kernelParams = ["resume_offset=499355648"];
# A stale resume_offset fails *silently*: the kernel finds no swsusp
# signature at the given block, logs `PM: Image not found (code -22)`, and
# cold-boots — the hibernated session is simply gone, with no error at
# hibernate time. Since the value above is hand-copied, check it on every
# activation so drift surfaces as a warning instead of as a lost session.
#
# Two known limits, both acceptable:
# * nixpkgs only recreates the swapfile when `size` changes, so on the one
# rebuild that resizes it this check still sees the old file and passes;
# it fires on the next rebuild instead. It is a drift detector, not a
# pre-flight gate.
# * Warn rather than fail: activation must not be blocked over this, and it
# is expected to read nothing at all on a fresh install where
# mkswap-var-lib-swapfile.service has not run yet.
system.activationScripts.checkResumeOffset.text = ''
swapfile=/var/lib/swapfile
want=499355648
if [ -f "$swapfile" ]; then
got=$(${pkgs.e2fsprogs}/bin/filefrag -v "$swapfile" 2>/dev/null \
| ${pkgs.gawk}/bin/awk '$1=="0:" {gsub(/\.\./,"",$4); print $4; exit}') || got=""
if [ -n "$got" ] && [ "$got" != "$want" ]; then
echo "WARNING: resume_offset is pinned to $want in modules/hosts/shadow/disks.nix," >&2
echo " but $swapfile now starts at block $got. Hibernation will cold-boot" >&2
echo " and lose the session until boot.kernelParams is updated to $got." >&2
fi
fi
'';
# This host sleeps via suspend-then-hibernate (hypridle in the dotfiles repo
# is what invokes it): S3 first so a quick return is instant, then a real
# suspend-to-disk once the machine has clearly been abandoned.
#
# Deliberately NOT hybrid-sleep, which writes the disk image up front on
# every single sleep. Measured here that cost ~59s of filesystem sync plus an
# 11GiB write before the screens even went off, and because an S3 wake
# invalidates the image the next boot always reported `PM: Image not found
# (code -22)` — full hibernation price paid every time, redeemed only on a
# power cut. suspend-then-hibernate defers that write until it is actually
# worth doing, and unlike hybrid-sleep it is a mode NVIDIA explicitly
# supports (see the system-sleep hook installed by the gpuNvidia module).
#
# HibernateDelaySec is set explicitly because systemd otherwise derives the
# handoff from battery discharge estimation, and this desktop has no system
# battery — the sole /sys/class/power_supply entry is a Logitech mouse
# reporting scope=Device, which is ignored for that purpose.
systemd.sleep.settings.Sleep.HibernateDelaySec = "30min";
}; };
} }
@@ -7,6 +7,93 @@
networking = { networking = {
hostName = "shadow"; hostName = "shadow";
}; };
# Windows lives on the 476G NVMe -- the one with three partitions: 16M
# "Microsoft reserved" MSR, 476G NTFS (fs-uuid 70E4E841E4E80AE4, the C:
# volume), 832M WinRE. Identified by layout rather than by nvmeXnY on
# purpose; see the enumeration note below.
#
# That disk has NO EFI System Partition of its own -- its 16M partition is a
# "Microsoft reserved" MSR with no filesystem. Windows was installed against
# the ESP that NixOS now owns: the 1G vfat partition fs-uuid 5873-AD05 on the
# 1.9T NVMe (that disk holds only two partitions, the ESP and the ext4 root
# deff63fc-1738-4cfc-a79c-06e58c78471a). So both OSes boot from one shared
# ESP; there is no separate Windows boot drive.
#
# The NixOS install reformatted that ESP, which wiped
# EFI/Microsoft/Boot/{bootmgfw.efi,BCD} and left Windows unbootable. That has
# since been repaired from WinRE and both files are back in place, so the
# menuentry below is live. Keep the recovery command to hand in case the ESP
# is ever reformatted again -- it has to be run from Windows install media or
# WinRE, because the BCD store cannot be generated from Linux:
#
# bcdboot C:\Windows /s S: /f UEFI (S: = the ESP, fs-uuid 5873-AD05)
#
# Copying bootmgfw.efi alone is not enough; without BCD it fails 0xc000000f.
# Note bcdboot also drops a copy of the Windows boot manager at
# EFI/BOOT/bootx64.efi -- see the fallback-path note in core/boot.nix.
#
# Chainloaded explicitly rather than via grub.useOSProber: deterministic,
# pulls in no extra tooling, and avoids probing every disk on each bootloader
# install. The `-f` guard makes the entry self-disabling: it renders only
# while bootmgfw.efi is actually present, so a future ESP reformat degrades
# to a missing menu item rather than one that boots into an error.
#
# fs-uuid is shadow's ESP, matching the "/boot" entry in disks.nix. Searching
# by UUID rather than (hdX,gptN) matters here: NVMe enumeration is not stable
# on this board -- /boot has been seen as both nvme0n1p1 and nvme1n1p1. For
# the same reason, re-derive device names before running anything
# destructive:
#
# lsblk -o NAME,SIZE,FSTYPE,PARTTYPENAME,UUID,MOUNTPOINT
#
# Windows' boot files now occupy ~30-60MiB of that shared 1G ESP and Windows
# Update rewrites them periodically, which is part of why
# grub.configurationLimit is held at 10 in core/boot.nix.
#
# Before dual-booting, turn off Windows Fast Startup. hiberfil.sys is present
# on the 476G NTFS volume, and Fast Startup plus this host's hibernation
# setup will corrupt any NTFS volume mounted read-write from both sides.
#
# On the GRUB script itself:
#
# The outer `search` + `-f` test run at *boot*, while GRUB parses grub.cfg —
# not at Nix eval time. That is why repairing Windows needed no rebuild: the
# entry started rendering as soon as bcdboot put bootmgfw.efi back, and it
# will stop again by itself if the file disappears.
#
# The `-n "$esp"` test is a *separate,
# nested* if rather than `[ -n "$esp" -a -f ... ]`: a failed search leaves
# $esp unset, and GRUB's `test` builtin evaluates both operands of `-a`
# rather than short-circuiting, so the combined form would still expand the
# `-f` to `()/...` and could complain about a bad device on every boot.
# Nesting guarantees the path test is not reached at all.
#
# No `insmod` is needed for the outer search: GRUB's EFI core image already
# embeds part_gpt/fat/search_fs_uuid, since it must find its own $prefix on
# the FAT ESP before reading this file. NixOS' own install-grub.pl relies on
# exactly that for the search it emits. The insmods inside the menuentry are
# kept only because `chain` genuinely is a separate module.
#
# The inner `search` deliberately repeats the outer one rather than reusing
# `set root=$esp`. It costs one extra disk scan, and only when the entry is
# actually selected, in exchange for a menuentry that is self-contained and
# cannot be broken by anything that clobbers $esp in between. This also
# matches the per-entry search install-grub.pl generates.
boot.loader.grub.extraEntries = ''
search --no-floppy --set=esp --fs-uuid 5873-AD05
if [ -n "$esp" ]; then
if [ -f ($esp)/EFI/Microsoft/Boot/bootmgfw.efi ]; then
menuentry "Windows Boot Manager" --class windows --class os {
insmod part_gpt
insmod fat
insmod chain
search --no-floppy --set=root --fs-uuid 5873-AD05
chainloader /EFI/Microsoft/Boot/bootmgfw.efi
}
fi
fi
'';
}; };
} }
+57
View File
@@ -10,10 +10,67 @@
efi = { efi = {
canTouchEfiVariables = true; canTouchEfiVariables = true;
}; };
# Pinned explicitly. grub.enable defaults to true, so this host silently
# switched from systemd-boot (used at install time) to GRUB when this
# block was added. NixOS never removes the previous bootloader's files
# or its NVRAM entry, so both lived on the ESP and the firmware kept
# booting the frozen systemd-boot menu. Keep this explicit, and never
# set systemd-boot.enable alongside it.
#
# Note what this does and does not do. Both of these lines are no-ops
# against nixpkgs' defaults (grub.enable is already true,
# systemd-boot.enable already false); their value is that a conflicting
# definition elsewhere now fails loudly instead of silently winning. They
# delete nothing — stale EFI/systemd/, loader/ and the leftover NVRAM
# entry have to be removed by hand on any host installed with
# systemd-boot. Done on shadow; NOT yet verified on t480.
#
# This is stock UEFI GRUB: NixOS installs EFI/<distro><esp>/grubx64.efi
# (here EFI/NixOS-boot/grubx64.efi) and registers a matching NVRAM entry
# via efibootmgr, because canTouchEfiVariables is true. That NVRAM entry
# is what makes GRUB the default; NixOS does not manage the removable
# fallback path EFI/BOOT/BOOTX64.EFI and we deliberately do not ask it to
# (grub.efiInstallAsRemovable, which would need canTouchEfiVariables=false,
# is only for firmware that ignores NVRAM entries — neither host needs it).
#
# On shadow that fallback path is NOT empty, though: bcdboot left a copy of
# the *Windows* boot manager at EFI/BOOT/bootx64.efi (byte-identical to
# EFI/Microsoft/Boot/bootmgfw.efi). Normal boots are unaffected because the
# firmware follows its NVRAM entry to GRUB. But it does mean that if NVRAM
# is ever cleared — CMOS reset, battery, firmware update — the fallback
# lands on Windows rather than GRUB. Left as-is by choice; recover by
# rerunning `nixos-rebuild boot` to reinstate the NVRAM entry.
systemd-boot.enable = false;
grub = { grub = {
enable = true;
efiSupport = true; efiSupport = true;
device = "nodev"; device = "nodev";
theme = pkgs.catppuccin-grub; theme = pkgs.catppuccin-grub;
# Bound the menu so the ESP cannot fill up with old kernels.
# install-grub.pl unlinks any $boot/kernels/* not referenced by a kept
# generation, so this genuinely caps ESP usage rather than just
# trimming the menu.
#
# Sizing, measured on shadow rather than assumed. Generations share
# aggressively in practice: 10 kept generations referenced 1 bzImage
# (13.3MiB) and 3 initrds (~33.4MiB each) for 114MiB total, i.e. ~11MiB
# per generation, not ~47MiB. Only a kernel or initrd change mints a new
# file — and the initrd does change more often than you'd expect, since
# nixpkgs embeds /etc/modprobe.d/nixos.conf inside it, so every kernel
# module parameter edit produces a fresh one.
#
# So the real bound is between ~11MiB and ~47MiB per generation
# depending on churn: 10 generations costs ~115MiB typically and ~470MiB
# worst case, against a 1GiB ESP that also carries grub/x86_64-efi, the
# catppuccin theme, and Windows' own boot files (bootmgfw.efi + BCD,
# ~30-60MiB) since Windows was bcdboot'd onto this same ESP. 10 keeps
# the worst case under half the partition.
#
# Keep this conservative: overflow fails as `copy ... or die` in
# install-grub.pl, which runs *after* the system profile has already
# been switched, so the result is an activated but unbootable
# generation. t480 reaches this module too and its ESP may be smaller.
configurationLimit = 10;
}; };
}; };
plymouth = { plymouth = {
+137 -4
View File
@@ -3,6 +3,7 @@
# For Intel+NVIDIA laptops (e.g. the T480), use gpuNvidiaPrime instead. # For Intel+NVIDIA laptops (e.g. the T480), use gpuNvidiaPrime instead.
flake.nixosModules.gpuNvidia = { flake.nixosModules.gpuNvidia = {
config, config,
lib,
pkgs, pkgs,
... ...
}: { }: {
@@ -15,15 +16,147 @@
hardware.nvidia = { hardware.nvidia = {
modesetting.enable = true; modesetting.enable = true;
# Proprietary kernel modules. Set to true only for Turing+ (RTX 20xx and # Open kernel modules. Mandatory to set explicitly on driver >= 560 —
# newer) where the open modules are well supported. # the option defaults to null there and nixpkgs asserts against null, so
open = false; # this line cannot simply be dropped.
#
# true because every card this module targets is Turing or later (RTX 20xx
# and newer), which is exactly what upstream recommends open modules for.
# Set false only for a genuinely pre-Turing card, which would want the
# gpuNvidia module pointed at a legacy package branch anyway.
#
# This is NOT cosmetic and NOT a version switch: `open` selects between two
# real builds of the same release — nvidia-open (~12MB) vs the proprietary
# nvidia-kernel-modules (~85MB). It has no effect on driver version, which
# comes solely from `package` below.
#
# Kernel modules only change on reboot, so `cat /proc/driver/nvidia/version`
# describes the RUNNING module and can disagree with this setting until the
# next boot. Compare /run/booted-system against /run/current-system to see
# a pending swap.
#
# mkDefault because "Turing or later" is an assumption about the hosts that
# happen to import this module today, not a property of the module itself.
# A future pre-Turing card can then override it at the host level instead of
# having to edit this file (and note that without mkDefault, importing both
# gpuNvidia and gpuNvidiaPrime would be an outright merge conflict rather
# than something resolvable).
open = lib.mkDefault true;
nvidiaSettings = true; nvidiaSettings = true;
# Fixes suspend/resume corruption on many setups. # Required for suspend/hibernate to come back with an intact framebuffer:
# this sets NVreg_PreserveVideoMemoryAllocations=1, so VRAM contents are
# spooled out across sleep instead of being discarded.
#
# The driver spools VRAM to NVreg_TemporaryFilePath, which nixpkgs leaves
# unset so it defaults to /tmp. Keep /tmp on a real filesystem — setting
# boot.tmp.useTmpfs would route a full VRAM dump into RAM, which on a
# large card is enough to break hibernation outright.
powerManagement.enable = true; powerManagement.enable = true;
# Which mechanism tells the driver about sleep transitions. Two options:
#
# true -> NVreg_UseKernelSuspendNotifiers=1; the driver hooks the
# in-kernel PM notifier chain and the systemd units are NOT
# generated.
# false -> nvidia-{suspend,hibernate,resume}.service call nvidia-sleep.sh
# around systemd-{suspend,hibernate}.service.
#
# Pinned to false deliberately. nixpkgs defaults this to
# `useOpenModules && version >= 595`, which is silently true here (open =
# true above, driver 595), and that path wedges the GPU on resume — the
# notifier callback warns and then the card never comes back:
#
# WARNING: nvidia/nv.c:4564 at nv_restore_user_channels+0x1d9/0x240
# nv_pm_notifier+0x35/0xa0 [nvidia]
# nvidia-modeset: ERROR: GPU:0: Failed detecting connected display devices
# NVRM: GPU0 _issueRpcAndWait: rpcSendMessage failed with status 0x11
#
# followed by an unrecoverable GSP RPC storm, i.e. black screens until a
# hard power cycle. Revisit once a newer driver fixes the notifier path;
# test by flipping this to true and confirming a hibernate round-trip
# relights the displays before leaving it that way.
powerManagement.kernelSuspendNotifier = false;
package = config.boot.kernelPackages.nvidiaPackages.stable; package = config.boot.kernelPackages.nvidiaPackages.stable;
}; };
# The nvidia-{suspend,hibernate,resume}.service units nixpkgs generates are
# wired only to systemd-{suspend,hibernate}.service. suspend-then-hibernate
# runs as a single systemd-suspend-then-hibernate.service that performs both
# transitions internally, so those units never fire for it and the GPU would
# sleep with VRAM unspooled.
#
# NVIDIA ships a hook that handles exactly this, dispatching on
# $SYSTEMD_SLEEP_ACTION and covering the suspend-after-failed-hibernate case
# too. nixpkgs sets `systemd.packages = nvidia_x11.out`, but that only
# collects lib/systemd/system/, never lib/systemd/system-sleep/, so upstream's
# hook is silently discarded. Install it by hand.
#
# It must NOT be installed verbatim, though. Past its suspend-then-hibernate
# branch the hook ends in an unconditional fallthrough:
#
# case "$1" in post) nvidia-sleep.sh "resume" ;; esac
#
# For plain suspend/hibernate that duplicates nvidia-resume.service, and the
# duplicate is destructive rather than merely redundant: nvidia-sleep.sh's
# RestoreVT() reads /run/nvidia-sleep/Xorg.vt_number, *deletes* it, then
# chvt's back. `chvt` lives in pkgs.kbd, which nixpkgs puts on
# nvidia-resume.service's `path` but which is absent from the stripped PATH
# systemd-sleep gives its hooks. So the hook consumes the VT file, fails to
# chvt, and the real unit then finds nothing to restore — no VT switch at
# all, i.e. the same black-screen-on-resume this module exists to avoid.
#
# Hence the first line of the wrapper: bail out unless systemd is actually
# doing suspend-then-hibernate, the one transition nixpkgs' units do not
# cover. Upstream's own comment notes that its suspend-then-hibernate path
# deliberately writes /proc/driver/nvidia/suspend directly and leaves the VT
# switch to nvidia-resume.service, so gating here loses nothing.
#
# The wrapper also has to PATCH A HOLE in upstream's dispatch. Its
# suspend-then-hibernate branch is:
#
# case "$1:$SYSTEMD_SLEEP_ACTION" in
# post:*) echo resume > ... ;;
# pre:hibernate) echo hibernate > ... ;;
# pre:suspend-after-failed-hibernate) echo suspend > ... ;;
# esac
#
# There is no `pre:suspend`. But that is exactly the FIRST leg of
# suspend-then-hibernate: systemd invokes the hook with $1=pre and
# SYSTEMD_SLEEP_ACTION=suspend before the S3 transition. It matches nothing,
# the case falls through, and the driver never gets its VRAM-spool handshake
# — so with NVreg_PreserveVideoMemoryAllocations=1 the GPU refuses to
# suspend and the whole operation aborts:
#
# nvidia 0000:2d:00.0: PM: failed to suspend async: error -5
# PM: Some devices failed to suspend, or early wake event detected
# systemd-sleep: Failed to put system to sleep. System resumed again:
# Input/output error
#
# Observed on this host: plain suspend and plain hibernate both worked (their
# nvidia-*.service units did the handshake) while suspend-then-hibernate
# failed at the S3 leg with no nvidia unit having run at all. So handle
# pre:suspend here, the same way upstream handles pre:hibernate, then hand
# everything else to upstream unchanged.
#
# Guarded on the same condition nixpkgs uses for the units: with the kernel
# suspend notifier active the driver handles transitions in-kernel and this
# hook must not also poke /proc/driver/nvidia/suspend.
environment.etc."systemd/system-sleep/nvidia" = lib.mkIf (config.hardware.nvidia.powerManagement.enable
&& !config.hardware.nvidia.powerManagement.kernelSuspendNotifier) {
source = pkgs.writeShellScript "nvidia-system-sleep-hook" ''
[ "$2" = "suspend-then-hibernate" ] || exit 0
# The gap upstream leaves: the S3 leg of suspend-then-hibernate.
if [ "$1" = "pre" ] && [ "$SYSTEMD_SLEEP_ACTION" = "suspend" ]; then
if [ -f /proc/driver/nvidia/suspend ]; then
echo suspend > /proc/driver/nvidia/suspend
fi
exit 0
fi
exec ${config.hardware.nvidia.package.out}/lib/systemd/system-sleep/nvidia "$@"
'';
};
environment.systemPackages = with pkgs; [ environment.systemPackages = with pkgs; [
vulkan-tools vulkan-tools
]; ];
-1
View File
@@ -1 +0,0 @@
/nix/store/dgd4pa9cl1n266zsbf8xi277p304zxcw-nixos-system-t480-26.05.20260803.531670d