{"slug": "claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up", "title": "(Claude Fable 5 + ChatGPT 5.6 Sol (Pro)) Untested nixpkgs patch to avoid filling up /boot with too many generations", "summary": "A NixOS developer has proposed an untested patch for nixpkgs that dynamically limits the number of boot menu generations based on available space on the /boot filesystem, preventing it from filling up. The patch adds a `dynamicConfigurationLimit` option with configurable margin and minimum generations, and only takes effect when kernels are copied to the boot partition.", "body_md": "| diff --git a/nixos/modules/system/boot/loader/grub/grub.nix b/nixos/modules/system/boot/loader/grub/grub.nix | |\n| index 042fd63cd254..6d319a3d3ca3 100644 | |\n| --- a/nixos/modules/system/boot/loader/grub/grub.nix | |\n| +++ b/nixos/modules/system/boot/loader/grub/grub.nix | |\n| @@ -90,6 +90,9 @@ let | |\n| else | |\n| args.efiBootloaderId; | |\n| timeout = if config.boot.loader.timeout == null then -1 else config.boot.loader.timeout; | |\n| + dynamicLimit = cfg.dynamicConfigurationLimit.enable; | |\n| + dynamicLimitMarginMB = cfg.dynamicConfigurationLimit.marginMB; | |\n| + dynamicLimitMinGenerations = cfg.dynamicConfigurationLimit.minGenerations; | |\n| theme = f cfg.theme; | |\n| inherit efiSysMountPoint; | |\n| inherit (args) devices; | |\n| @@ -641,6 +644,64 @@ in | |\n| ''; | |\n| }; | |\n| + dynamicConfigurationLimit = { | |\n| + enable = mkOption { | |\n| + default = false; | |\n| + type = types.bool; | |\n| + description = '' | |\n| + Size the boot menu to the boot filesystem instead of a fixed | |\n| + count. At menu-build time, the newest generations whose | |\n| + kernels, initrds and Xen images fit within the filesystem | |\n| + capacity — minus space already used by unmanaged files and minus | |\n| + {option}`boot.loader.grub.dynamicConfigurationLimit.marginMB` | |\n| + — are kept; older ones are dropped from the boot menu with a | |\n| + warning, and their files are removed from the kernels | |\n| + directory before any copying so the space is actually | |\n| + available. Files shared between generations are only counted | |\n| + once. The configuration being activated is always kept. | |\n| + | |\n| + Only takes effect when kernels are copied to the boot | |\n| + partition (see {option}`boot.loader.grub.copyKernels`, which | |\n| + is forced on when `/boot` is a separate filesystem); without | |\n| + copying, GRUB reads `/nix/store` directly and the boot | |\n| + partition has no space pressure. Composes with | |\n| + {option}`boot.loader.grub.configurationLimit`, which still | |\n| + applies first as a hard cap. Dropped generations remain in | |\n| + the system profile: they are still only deleted by garbage | |\n| + collection, and reappear in the menu if space allows. | |\n| + ''; | |\n| + }; | |\n| + | |\n| + marginMB = mkOption { | |\n| + default = 50; | |\n| + example = 256; | |\n| + type = types.ints.positive; | |\n| + description = '' | |\n| + Safety margin in MB left unallocated on the boot filesystem. | |\n| + Covers estimate error (allocation overhead beyond block | |\n| + rounding, directory growth), newly generated secret initrds or | |\n| + growth beyond their installed size, files newly written or | |\n| + enlarged by `extraPrepareConfig`, and bootloader updates. Raise | |\n| + this if those writes can be large. | |\n| + ''; | |\n| + }; | |\n| + | |\n| + minGenerations = mkOption { | |\n| + default = 2; | |\n| + type = types.ints.positive; | |\n| + description = '' | |\n| + Keep at least this many of the newest generations even when | |\n| + the estimate says they exceed the budget. The copy may still | |\n| + succeed because the estimate is conservative; if the files | |\n| + genuinely cannot fit, menu generation fails exactly as it | |\n| + does without this option. The default of 2 keeps the | |\n| + activated generation plus one rollback when possible. This | |\n| + floor is global across the | |\n| + default and named system profiles; it is not applied per profile. | |\n| + ''; | |\n| + }; | |\n| + }; | |\n| + | |\n| copyKernels = mkOption { | |\n| default = false; | |\n| type = types.bool; | |\n| diff --git a/nixos/modules/system/boot/loader/grub/install-grub.pl b/nixos/modules/system/boot/loader/grub/install-grub.pl | |\n| index f61c91f02db8..e3763b200b1c 100644 | |\n| --- a/nixos/modules/system/boot/loader/grub/install-grub.pl | |\n| +++ b/nixos/modules/system/boot/loader/grub/install-grub.pl | |\n| @@ -441,11 +441,22 @@ $conf .= \"\\n\"; | |\n| my %copied; | |\n| make_path(\"$bootPath/kernels\", { mode => 0755 }) if $copyKernels; | |\n| +# kernelsDirName($path): the file name a store path gets in $bootPath/kernels. | |\n| +# $path: an absolute /nix/store path. | |\n| +# Returns the store-relative path with slashes flattened to dashes; this is | |\n| +# the single source of truth for kernels-dir naming, shared by the copier | |\n| +# and the dynamic-limit footprint accounting. | |\n| +sub kernelsDirName { | |\n| + my ($path) = @_; | |\n| + $path =~ /\\/nix\\/store\\/(.*)/ or die \"not a store path: $path\\n\"; | |\n| + my $name = $1; $name =~ s/\\//-/g; | |\n| + return $name; | |\n| +} | |\n| + | |\n| sub copyToKernelsDir { | |\n| my ($path) = @_; | |\n| return $grubStore->path . substr($path, length(\"/nix/store\")) unless $copyKernels; | |\n| - $path =~ /\\/nix\\/store\\/(.*)/ or die; | |\n| - my $name = $1; $name =~ s/\\//-/g; | |\n| + my $name = kernelsDirName($path); | |\n| my $dst = \"$bootPath/kernels/$name\"; | |\n| # Don't copy the file if $dst already exists. This means that we | |\n| # have to create $dst atomically to prevent partially copied | |\n| @@ -561,6 +572,206 @@ sub addGeneration { | |\n| } | |\n| } | |\n| +# --- Dynamic configuration limit -------------------------------------------- | |\n| +# When enabled, size the boot menu to the filesystem holding the kernels dir | |\n| +# instead of a fixed count. Only meaningful when kernels are copied to | |\n| +# $bootPath (copyKernels, possibly forced above because /boot is a separate | |\n| +# filesystem); without copying, GRUB reads the store and /boot has no space | |\n| +# pressure. All of the following is inert when $dynamicLimit is false. | |\n| + | |\n| +my $dynamicLimit = get(\"dynamicLimit\") eq \"true\" && $copyKernels; | |\n| +my $dynamicLimitMarginBytes = int(get(\"dynamicLimitMarginMB\")) * 1000 * 1000; | |\n| +my $dynamicLimitMinGenerations = int(get(\"dynamicLimitMinGenerations\")); | |\n| +my %keptLink; | |\n| +my %regeneratedSecrets; | |\n| + | |\n| +# roundUp($size, $granule): bytes a file occupies on the filesystem. | |\n| +# $size: byte count, >= 0. | |\n| +# $granule: allocation unit in bytes (filesystem block/cluster size), > 0. | |\n| +# Returns the smallest multiple of $granule that is >= $size. | |\n| +sub roundUp { | |\n| + my ($size, $granule) = @_; | |\n| + die \"roundUp: bad arguments\\n\" unless $granule > 0 && $size >= 0; | |\n| + return int(($size + $granule - 1) / $granule) * $granule; | |\n| +} | |\n| + | |\n| +# generationFootprint($path): files a generation needs in $bootPath/kernels. | |\n| +# $path: toplevel directory of the generation (store path or profile link). | |\n| +# Returns a hashref of kernels-dir file name -> source size in bytes, | |\n| +# covering kernel, initrd and xen.gz of the generation and of each of its | |\n| +# specialisations. Existing generated initrd-secrets files are charged | |\n| +# at their installed size. Selection also reserves enough scratch space | |\n| +# for the largest such file, because addEntry() creates its replacement | |\n| +# beside the old file before atomically renaming it. | |\n| +sub generationFootprint { | |\n| + my ($path) = @_; | |\n| + my %footprint; | |\n| + foreach my $system ($path, sort (glob \"$path/specialisation/*\")) { | |\n| + foreach my $file (\"kernel\", \"initrd\", \"xen.gz\") { | |\n| + next unless -e \"$system/$file\"; | |\n| + my $source = Cwd::abs_path(\"$system/$file\"); | |\n| + $footprint{kernelsDirName($source)} = -s $source; | |\n| + } | |\n| + if (-e -x \"$system/append-initrd-secrets\") { | |\n| + my $name = basename(Cwd::abs_path($system)) . \"-secrets\"; | |\n| + my $installed = \"$bootPath/kernels/$name\"; | |\n| + $regeneratedSecrets{$name} = 1; | |\n| + $footprint{$name} = -s $installed if -f $installed; | |\n| + } | |\n| + } | |\n| + return \\%footprint; | |\n| +} | |\n| + | |\n| +# bootFsBudget(): how many bytes the kernels dir may occupy. | |\n| +# Returns ($budget, $granule), both in bytes: the capacity of the | |\n| +# filesystem at $bootPath, minus space used by files outside our | |\n| +# management (grub itself, other OSes, extra files), minus the configured | |\n| +# margin. Existing files in $bootPath/kernels are excluded from \"used\" | |\n| +# because the pre-prune below reclaims whichever of them the kept | |\n| +# generations do not reference. | |\n| +sub bootFsBudget { | |\n| + my ($status, @out) = runCommand(\"stat\", \"-f\", \"-c\", \"%S %b %a\", $bootPath); | |\n| + die \"stat -f $bootPath failed\\n\" if $status != 0 || !@out; | |\n| + my ($granule, $blocks, $avail) = split ' ', $out[0]; | |\n| + die \"cannot parse stat -f output: $out[0]\\n\" unless defined $avail && $granule > 0; | |\n| + my $total = $granule * $blocks; | |\n| + my $free = $granule * $avail; | |\n| + my $managed = 0; | |\n| + foreach my $fn (glob \"$bootPath/kernels/*\") { | |\n| + $managed += roundUp(-s $fn, $granule) if -f $fn; | |\n| + } | |\n| + my $unmanaged = $total - $free - $managed; | |\n| + $unmanaged = 0 if $unmanaged < 0; | |\n| + return ($total - $unmanaged - $dynamicLimitMarginBytes, $granule); | |\n| +} | |\n| + | |\n| +# profileCandidates($profile): statically capped, bootable generation links. | |\n| +# $profile: path to a system profile without a generation suffix. | |\n| +# Returns the links addProfile() can actually turn into menu entries. | |\n| +# Non-positive limits match addProfile(): no profile generations are added. | |\n| +sub profileCandidates { | |\n| + my ($profile) = @_; | |\n| + return () if $configurationLimit <= 0; | |\n| + my @links = sort { nrFromGen($b) <=> nrFromGen($a) } (glob \"$profile-*-link\"); | |\n| + splice(@links, $configurationLimit) if @links > $configurationLimit; | |\n| + my @bootable; | |\n| + foreach my $link (@links) { | |\n| + my @required = (\"nixos-version\", \"kernel\", \"initrd\", \"init\", \"kernel-params\"); | |\n| + if (grep { ! -e \"$link/$_\" } @required) { | |\n| + warn \"skipping corrupt system profile entry ‘$link’\\n\"; | |\n| + next; | |\n| + } | |\n| + push @bootable, $link; | |\n| + } | |\n| + return @bootable; | |\n| +} | |\n| + | |\n| +# secretScratch($footprint, $granule): temporary space needed to regenerate | |\n| +# initrd-secrets files without deleting the currently bootable copies first. | |\n| +# $footprint: generationFootprint() result. | |\n| +# $granule: filesystem allocation unit in bytes. | |\n| +# Returns the largest allocated size among regenerated files in the footprint. | |\n| +sub secretScratch { | |\n| + my ($footprint, $granule) = @_; | |\n| + my $scratch = 0; | |\n| + foreach my $name (keys %$footprint) { | |\n| + next unless $regeneratedSecrets{$name}; | |\n| + my $size = roundUp($footprint->{$name}, $granule); | |\n| + $scratch = $size if $size > $scratch; | |\n| + } | |\n| + return $scratch; | |\n| +} | |\n| + | |\n| +# selectGenerationsThatFit(): fill %keptLink and free the space that | |\n| +# excluded generations release. | |\n| +# Consumes no arguments; reads the profile links and $defaultConfig. | |\n| +# The footprint of $defaultConfig is charged unconditionally first, so | |\n| +# the entry being activated is always in the menu and either fits or | |\n| +# fails loudly at copy time, exactly as today. Candidates are all | |\n| +# profile links after the static $configurationLimit cap, walked newest | |\n| +# first by symlink mtime (the same clock the menu displays); files | |\n| +# shared between generations are charged once; the first generation | |\n| +# over budget ends the run so the menu has no holes, except that the | |\n| +# newest $dynamicLimitMinGenerations are always kept. Finally, files | |\n| +# in $bootPath/kernels not referenced by any kept generation are | |\n| +# deleted here, BEFORE any copying, because the post-copy obsolete-file | |\n| +# cleanup at the end of this script runs too late to make room. | |\n| +sub selectGenerationsThatFit { | |\n| + my $defaultPath = Cwd::abs_path($defaultConfig); | |\n| + die \"cannot resolve activated configuration $defaultConfig\\n\" unless defined $defaultPath; | |\n| + foreach my $required (\"kernel\", \"initrd\", \"init\", \"kernel-params\") { | |\n| + die \"activated configuration $defaultConfig is missing $required\\n\" | |\n| + unless -e \"$defaultConfig/$required\"; | |\n| + } | |\n| + my ($budget, $granule) = bootFsBudget(); | |\n| + my @candidates; | |\n| + foreach my $profile (\"/nix/var/nix/profiles/system\", | |\n| + grep { basename($_) =~ /^\\w+$/ } (glob \"/nix/var/nix/profiles/system-profiles/*\")) { | |\n| + push @candidates, profileCandidates($profile); | |\n| + } | |\n| + my @ordered = sort { lstat($b)->mtime <=> lstat($a)->mtime | |\n| + || nrFromGen($b) <=> nrFromGen($a) } @candidates; | |\n| + my %counted; | |\n| + my $used = 0; | |\n| + my $secretScratch = 0; | |\n| + my $marginal = sub { | |\n| + my ($footprint) = @_; | |\n| + my $sum = 0; | |\n| + foreach my $name (keys %$footprint) { | |\n| + $sum += roundUp($footprint->{$name}, $granule) unless $counted{$name}; | |\n| + } | |\n| + my $candidateScratch = secretScratch($footprint, $granule); | |\n| + $sum += $candidateScratch - $secretScratch if $candidateScratch > $secretScratch; | |\n| + return ($sum, $candidateScratch); | |\n| + }; | |\n| + my $commit = sub { | |\n| + my ($footprint, $candidateScratch) = @_; | |\n| + $counted{$_} = 1 foreach keys %$footprint; | |\n| + $secretScratch = $candidateScratch if $candidateScratch > $secretScratch; | |\n| + }; | |\n| + my $defaultFootprint = generationFootprint($defaultConfig); | |\n| + my ($defaultCost, $defaultScratch) = $marginal->($defaultFootprint); | |\n| + $used += $defaultCost; | |\n| + $commit->($defaultFootprint, $defaultScratch); | |\n| + my $fits = 1; | |\n| + foreach my $link (@ordered) { | |\n| + my $footprint = generationFootprint($link); | |\n| + my ($cost, $candidateScratch) = $marginal->($footprint); | |\n| + $fits = 0 if $used + $cost > $budget; | |\n| + my $linkPath = Cwd::abs_path($link); | |\n| + my $pinned = defined($linkPath) && $linkPath eq $defaultPath; | |\n| + if ($pinned || scalar(keys %keptLink) < $dynamicLimitMinGenerations || $fits) { | |\n| + $keptLink{$link} = 1; | |\n| + $used += $cost; | |\n| + $commit->($footprint, $candidateScratch); | |\n| + } | |\n| + } | |\n| + if (scalar(keys %keptLink) < scalar(@ordered)) { | |\n| + my @skipped = grep { !$keptLink{$_} } @ordered; | |\n| + printf STDERR \"GRUB: keeping %d of %d generations, ~%d MB of a %d MB budget on %s \" | |\n| + . \"(margin %d MB); dropping %s from the boot menu. They remain in the \" | |\n| + . \"system profile until garbage collected and reappear if space allows.\\n\", | |\n| + scalar(keys %keptLink), scalar(@ordered), $used / 1e6, | |\n| + ($budget > 0 ? $budget : 0) / 1e6, $bootPath, $dynamicLimitMarginBytes / 1e6, | |\n| + join(\", \", map { basename($_) } @skipped); | |\n| + } | |\n| + foreach my $fn (glob \"$bootPath/kernels/*\") { | |\n| + next unless -f $fn; | |\n| + my $name = basename($fn); | |\n| + next if $counted{$name}; | |\n| + print STDERR \"removing $fn to make room before copying\\n\"; | |\n| + # A failed unlink means the budget premise is off, but aborting | |\n| + # here strands /boot mid-prune with the old grub.cfg; continuing | |\n| + # gives the run its best chance to reach the atomic cfg switch, | |\n| + # and a genuine shortfall still fails loudly at copy time. | |\n| + unlink $fn or print STDERR \"warning: could not remove $fn: $!\\n\"; | |\n| + } | |\n| +} | |\n| + | |\n| +selectGenerationsThatFit() if $dynamicLimit; | |\n| + | |\n| +# ----------------------------------------------------------------------------- | |\n| + | |\n| # Add default entries. | |\n| $conf .= \"$extraEntries\\n\" if $extraEntriesBeforeNixOS; | |\n| @@ -588,6 +799,7 @@ sub addProfile { | |\n| my $curEntry = 0; | |\n| foreach my $link (@links) { | |\n| last if $curEntry++ >= $configurationLimit; | |\n| + next if $dynamicLimit && !$keptLink{$link}; | |\n| if (! -e \"$link/nixos-version\") { | |\n| warn \"skipping corrupt system profile entry ‘$link’\\n\"; | |\n| next; | |\n| diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |\n| index 90241b92cd5f..474d757bb29f 100644 | |\n| --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |\n| +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot-builder.py | |\n| @@ -28,6 +28,9 @@ DISTRO_NAME = \"@distroName@\" | |\n| NIX = \"@nix@\" | |\n| SYSTEMD = \"@systemd@\" | |\n| CONFIGURATION_LIMIT = int(\"@configurationLimit@\") | |\n| +DYNAMIC_LIMIT = \"@dynamicLimit@\" == \"1\" | |\n| +DYNAMIC_LIMIT_MARGIN_BYTES = int(\"@dynamicLimitMarginMB@\") * 1000 * 1000 | |\n| +DYNAMIC_LIMIT_MIN_GENERATIONS = int(\"@dynamicLimitMinGenerations@\") | |\n| REBOOT_FOR_BITLOCKER = bool(\"@rebootForBitlocker@\") | |\n| CAN_TOUCH_EFI_VARIABLES = \"@canTouchEfiVariables@\" | |\n| GRACEFUL = \"@graceful@\" | |\n| @@ -228,7 +231,52 @@ def write_entry(profile: str | None, generation: int, specialisation: str | None | |\n| tmp_path.rename(entry_file) | |\n| -def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |\n| +def limit_generations( | |\n| + configurations: list[SystemIdentifier], | |\n| + default_config: Path | None, | |\n| +) -> list[SystemIdentifier]: | |\n| + \"\"\" | |\n| + Apply the static limit without discarding the activated generation. | |\n| + | |\n| + configurations: generations for one profile, ordered oldest first. | |\n| + default_config: activated toplevel, or None when no generation needs | |\n| + pinning. | |\n| + Returns: the same slice stock systemd-boot would select. When | |\n| + default_config is not None, an activated generation outside | |\n| + a positive newest-generation slice replaces that slice's | |\n| + oldest member instead of being discarded. | |\n| + \"\"\" | |\n| + if CONFIGURATION_LIMIT == 0: | |\n| + return configurations | |\n| + limited = configurations[-CONFIGURATION_LIMIT:] | |\n| + if default_config is None or not limited: | |\n| + return limited | |\n| + default_path = default_config.resolve() | |\n| + active = next( | |\n| + ( | |\n| + gen | |\n| + for gen in reversed(configurations) | |\n| + if generation_dir(gen.profile, gen.generation).resolve() == default_path | |\n| + ), | |\n| + None, | |\n| + ) | |\n| + if active is None or active in limited: | |\n| + return limited | |\n| + return [active, *limited[1:]] | |\n| + | |\n| + | |\n| +def get_generations( | |\n| + profile: str | None = None, | |\n| + default_config: Path | None = None, | |\n| +) -> list[SystemIdentifier]: | |\n| + \"\"\" | |\n| + Read a system profile's generations and apply the static cap. | |\n| + | |\n| + profile: named system profile, or None for the default system profile. | |\n| + default_config: activated toplevel that must survive a positive cap, or | |\n| + None to preserve stock slicing exactly. | |\n| + Returns: capped generation identifiers ordered oldest first. | |\n| + \"\"\" | |\n| gen_list = run( | |\n| [ | |\n| f\"{NIX}/bin/nix-env\", | |\n| @@ -242,7 +290,6 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |\n| gen_lines = gen_list.split(\"\\n\") | |\n| gen_lines.pop() | |\n| - configurationLimit = CONFIGURATION_LIMIT | |\n| configurations = [ | |\n| SystemIdentifier( | |\n| profile=profile, | |\n| @@ -251,7 +298,7 @@ def get_generations(profile: str | None = None) -> list[SystemIdentifier]: | |\n| ) | |\n| for line in gen_lines | |\n| ] | |\n| - return configurations[-configurationLimit:] | |\n| + return limit_generations(configurations, default_config) | |\n| def remove_old_entries(gens: list[SystemIdentifier]) -> None: | |\n| @@ -297,6 +344,192 @@ def get_profiles() -> list[str]: | |\n| else: | |\n| return [] | |\n| +def round_up(size: int, granule: int) -> int: | |\n| + \"\"\" | |\n| + Round up to a whole number of allocation units. | |\n| + | |\n| + size: a byte count, >= 0. | |\n| + granule: the allocation unit in bytes (the FAT cluster size here), > 0. | |\n| + Returns: the smallest multiple of granule that is >= size, i.e. the | |\n| + bytes the file actually occupies on the filesystem. | |\n| + \"\"\" | |\n| + if granule <= 0 or size < 0: | |\n| + raise ValueError(f\"round_up: invalid size {size} or granule {granule}\") | |\n| + return ((size + granule - 1) // granule) * granule | |\n| + | |\n| + | |\n| +def newest_first(gens: list[SystemIdentifier]) -> list[SystemIdentifier]: | |\n| + \"\"\" | |\n| + Sort generations newest first. | |\n| + | |\n| + gens: generations, possibly from several profiles, in any order. | |\n| + Returns: a new list ordered by descending creation time of the profile | |\n| + symlink (generation numbers are not comparable across | |\n| + profiles), ties broken by descending generation number. | |\n| + \"\"\" | |\n| + def key(gen: SystemIdentifier) -> tuple[float, int]: | |\n| + link = generation_dir(gen.profile, gen.generation) | |\n| + return (os.lstat(link).st_ctime, gen.generation) | |\n| + return sorted(gens, key=key, reverse=True) | |\n| + | |\n| + | |\n| +def copied_file_size(source: Path) -> tuple[Path, int]: | |\n| + \"\"\" | |\n| + Determine the destination and installed size of a copied boot payload. | |\n| + | |\n| + source: pristine store file copied by write_entry(). | |\n| + Returns: destination relative to BOOT_MOUNT_POINT and the larger of the | |\n| + source size and an existing destination size. The latter | |\n| + includes initrd data already appended by boot.initrd.secrets. | |\n| + \"\"\" | |\n| + destination = copy_from_file(source, True) | |\n| + source_size = source.resolve().stat().st_size | |\n| + installed = BOOT_MOUNT_POINT / destination | |\n| + installed_size = installed.stat().st_size if installed.is_file() else 0 | |\n| + return destination, max(source_size, installed_size) | |\n| + | |\n| + | |\n| +def generation_footprint(gen: SystemIdentifier) -> dict[Path, int]: | |\n| + \"\"\" | |\n| + The files a generation needs on the boot filesystem. | |\n| + | |\n| + gen: the generation; its specialisations are included. | |\n| + Returns: destination path (relative to BOOT_MOUNT_POINT) -> byte size | |\n| + of the source that write_entry() would copy there. Existing | |\n| + copied payloads and entry files are measured too, so initrd | |\n| + data already appended by `boot.initrd.secrets` and entries | |\n| + larger than one cluster are included. Further growth during | |\n| + this install must fit within the margin. | |\n| + \"\"\" | |\n| + bootspec = get_bootspec(gen.profile, gen.generation) | |\n| + variants: list[tuple[str | None, BootSpec]] = [(None, bootspec)] | |\n| + variants += bootspec.specialisations.items() | |\n| + footprint: dict[Path, int] = {} | |\n| + for specialisation, spec in variants: | |\n| + for source in (spec.kernel, spec.initrd, spec.devicetree): | |\n| + if source is not None: | |\n| + destination, size = copied_file_size(source) | |\n| + footprint[destination] = size | |\n| + entry = Path(\"loader/entries\") / generation_conf_filename(gen.profile, gen.generation, specialisation) | |\n| + installed_entry = BOOT_MOUNT_POINT / entry | |\n| + footprint[entry] = max(1, installed_entry.stat().st_size if installed_entry.is_file() else 0) | |\n| + return footprint | |\n| + | |\n| + | |\n| +def tracked_extra_files() -> set[Path]: | |\n| + \"\"\" | |\n| + Return destinations owned by systemd-boot's extra-file marker tree. | |\n| + | |\n| + Returns: absolute paths below BOOT_MOUNT_POINT corresponding to marker | |\n| + files below NIXOS_DIR/.extra-files. | |\n| + \"\"\" | |\n| + marker_root = BOOT_MOUNT_POINT / NIXOS_DIR / \".extra-files\" | |\n| + if not marker_root.is_dir(): | |\n| + return set() | |\n| + return { | |\n| + BOOT_MOUNT_POINT / marker.relative_to(marker_root) | |\n| + for marker in marker_root.rglob(\"*\") | |\n| + if marker.is_file() | |\n| + } | |\n| + | |\n| + | |\n| +def boot_fs_budget() -> tuple[int, int]: | |\n| + \"\"\" | |\n| + How much of the boot filesystem the NixOS-managed files may occupy. | |\n| + | |\n| + Returns: (budget, cluster), both in bytes. budget is the filesystem | |\n| + capacity minus the space used by files we do not manage | |\n| + (systemd-boot itself, other OSes, and marker-tracked | |\n| + extraFiles) minus the configured safety margin. Generation | |\n| + payloads and entries are excluded from \"used\" because | |\n| + remove_old_entries() reclaims whichever of them the kept | |\n| + generations do not reference. | |\n| + \"\"\" | |\n| + st = os.statvfs(BOOT_MOUNT_POINT) | |\n| + cluster = st.f_frsize | |\n| + total = st.f_frsize * st.f_blocks | |\n| + free = st.f_frsize * st.f_bavail | |\n| + managed = 0 | |\n| + tracked_extras = tracked_extra_files() | |\n| + nixos_dir = BOOT_MOUNT_POINT / NIXOS_DIR | |\n| + if nixos_dir.is_dir(): | |\n| + for path in nixos_dir.iterdir(): | |\n| + if not path.is_dir() and path not in tracked_extras: | |\n| + managed += round_up(path.stat().st_size, cluster) | |\n| + entries_dir = BOOT_MOUNT_POINT / \"loader/entries\" | |\n| + if entries_dir.is_dir(): | |\n| + for path in entries_dir.glob(\"nixos*-generation-[1-9]*.conf\", case_sensitive=False): | |\n| + if path not in tracked_extras: | |\n| + managed += round_up(path.stat().st_size, cluster) | |\n| + unmanaged = max(0, (total - free) - managed) | |\n| + return total - unmanaged - DYNAMIC_LIMIT_MARGIN_BYTES, cluster | |\n| + | |\n| + | |\n| +def select_generations_that_fit(gens: list[SystemIdentifier], default_config: Path) -> list[SystemIdentifier]: | |\n| + \"\"\" | |\n| + Drop the oldest generations from the boot menu when they cannot fit. | |\n| + | |\n| + gens: candidate generations, already capped by the static | |\n| + configurationLimit if one is set. | |\n| + default_config: toplevel directory of the configuration being | |\n| + activated; its generation is always kept when it is | |\n| + registered in a profile (limit_generations() already | |\n| + rescued it from the static cap), as are the | |\n| + DYNAMIC_LIMIT_MIN_GENERATIONS newest ones. An | |\n| + unregistered configuration cannot be pinned: a warning | |\n| + is printed and selection proceeds without a pin. | |\n| + Returns: the subset of gens to install, in gens' original order. The | |\n| + kept set is a contiguous newest-first run plus the pinned | |\n| + generation: files shared between generations are charged | |\n| + once, and the first generation that does not fit ends the | |\n| + run so the boot menu never has holes. | |\n| + \"\"\" | |\n| + budget, cluster = boot_fs_budget() | |\n| + default_path = default_config.resolve() | |\n| + pinned = { | |\n| + gen | |\n| + for gen in gens | |\n| + if generation_dir(gen.profile, gen.generation).resolve() == default_path | |\n| + } | |\n| + if not gens: | |\n| + return gens | |\n| + if not pinned: | |\n| + # Activating a toplevel never registered in any profile (direct | |\n| + # switch-to-configuration on a build result). Stock tolerates it: | |\n| + # no entry is written for it and loader.conf keeps its previous | |\n| + # default. Selection proceeds without a pin — nothing in /boot | |\n| + # belongs to the unregistered configuration, so pruning on behalf | |\n| + # of the kept generations cannot break it — rather than aborting | |\n| + # the whole bootloader update. | |\n| + print(f\"systemd-boot: warning: no profile generation matches {default_path}; \" | |\n| + \"the dynamic limit cannot pin the activated configuration and the \" | |\n| + \"boot menu will not contain an entry for it\", file=sys.stderr) | |\n| + ordered = newest_first(gens) | |\n| + kept: set[SystemIdentifier] = set() | |\n| + counted: set[Path] = set() | |\n| + used = 0 | |\n| + fits = True | |\n| + for gen in ordered: | |\n| + footprint = generation_footprint(gen) | |\n| + marginal = sum(round_up(size, cluster) for path, size in footprint.items() if path not in counted) | |\n| + fits = fits and used + marginal <= budget | |\n| + if gen in pinned or len(kept) < DYNAMIC_LIMIT_MIN_GENERATIONS or fits: | |\n| + kept.add(gen) | |\n| + counted |= footprint.keys() | |\n| + used += marginal | |\n| + if not kept or not pinned <= kept: | |\n| + raise RuntimeError(f\"dynamic limit selection violated its invariant: kept={kept} pinned={pinned}\") | |\n| + if len(kept) < len(gens): | |\n| + skipped = \", \".join(str(gen.generation) for gen in ordered if gen not in kept) | |\n| + print(f\"systemd-boot: keeping {len(kept)} of {len(gens)} generations, \" | |\n| + f\"~{used // 10**6} MB of a {max(budget, 0) // 10** 6} MB budget on {BOOT_MOUNT_POINT} \" | |\n| + f\"(margin {DYNAMIC_LIMIT_MARGIN_BYTES // 10**6} MB); dropping older \" | |\n| + f\"generation(s) {skipped} from the boot menu. They remain in the \" | |\n| + \"system profile until garbage collected and reappear if space allows.\", | |\n| + file=sys.stderr) | |\n| + return [gen for gen in gens if gen in kept] | |\n| + | |\n| + | |\n| def install_bootloader(args: argparse.Namespace) -> None: | |\n| try: | |\n| with open(\"/etc/machine-id\") as machine_file: | |\n| @@ -376,16 +609,23 @@ def install_bootloader(args: argparse.Namespace) -> None: | |\n| (BOOT_MOUNT_POINT / NIXOS_DIR).mkdir(parents=True, exist_ok=True) | |\n| (BOOT_MOUNT_POINT / \"loader/entries\").mkdir(parents=True, exist_ok=True) | |\n| - gens = get_generations() | |\n| + default_config = Path(args.default_config).resolve() | |\n| + generation_pin = default_config if DYNAMIC_LIMIT else None | |\n| + gens = get_generations(default_config=generation_pin) | |\n| for profile in get_profiles(): | |\n| - gens += get_generations(profile) | |\n| + gens += get_generations(profile, generation_pin) | |\n| + if DYNAMIC_LIMIT: | |\n| + gens = select_generations_that_fit(gens, default_config) | |\n| + | |\n| + # Pruning runs before any copying, so files owned only by generations | |\n| + # that select_generations_that_fit() dropped are reclaimed first. | |\n| remove_old_entries(gens) | |\n| for gen in gens: | |\n| try: | |\n| bootspec = get_bootspec(gen.profile, gen.generation) | |\n| - is_default = Path(bootspec.init).parent == Path(args.default_config) | |\n| + is_default = Path(bootspec.init).parent.resolve() == default_config | |\n| write_entry(*gen, machine_id, bootspec, current=is_default) | |\n| for specialisation in bootspec.specialisations.keys(): | |\n| write_entry(gen.profile, gen.generation, specialisation, machine_id, bootspec, current=is_default) | |\n| diff --git a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |\n| index 28ba374272f3..32dfcc0e1b67 100644 | |\n| --- a/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |\n| +++ b/nixos/modules/system/boot/loader/systemd-boot/systemd-boot.nix | |\n| @@ -55,6 +55,10 @@ let | |\n| configurationLimit = if cfg.configurationLimit == null then 0 else cfg.configurationLimit; | |\n| + dynamicLimit = if cfg.dynamicConfigurationLimit.enable then \"1\" else \"0\"; | |\n| + dynamicLimitMarginMB = cfg.dynamicConfigurationLimit.marginMB; | |\n| + dynamicLimitMinGenerations = cfg.dynamicConfigurationLimit.minGenerations; | |\n| + | |\n| inherit (cfg) | |\n| consoleMode | |\n| graceful | |\n| @@ -243,6 +247,61 @@ in | |\n| ''; | |\n| }; | |\n| + dynamicConfigurationLimit = { | |\n| + enable = mkOption { | |\n| + default = false; | |\n| + type = types.bool; | |\n| + description = '' | |\n| + Size the boot menu to the boot filesystem instead of a fixed | |\n| + count. At bootloader install time, the newest generations whose | |\n| + kernels, initrds and devicetrees fit within the filesystem | |\n| + capacity — minus space used by currently installed unmanaged | |\n| + files (systemd-boot itself, other OSes, extraFiles) and minus | |\n| + {option}`boot.loader.systemd-boot.dynamicConfigurationLimit.marginMB` | |\n| + — are kept; older ones are dropped from the boot menu with a | |\n| + warning. Files shared between generations are only counted | |\n| + once. The generation being activated is always kept. | |\n| + | |\n| + Composes with {option}`boot.loader.systemd-boot.configurationLimit`, | |\n| + which still applies first as a hard cap; when necessary, the | |\n| + activated generation replaces the oldest member of that cap. | |\n| + Dropped generations remain in the system profile: they are still | |\n| + only deleted by garbage collection, and reappear in the menu if | |\n| + space allows. | |\n| + ''; | |\n| + }; | |\n| + | |\n| + marginMB = mkOption { | |\n| + default = 50; | |\n| + example = 256; | |\n| + type = types.ints.positive; | |\n| + description = '' | |\n| + Safety margin in MB left unallocated on the boot filesystem. | |\n| + Covers estimate error (FAT overhead beyond cluster rounding, | |\n| + directory growth), growth beyond an already installed initrd | |\n| + produced by `boot.initrd.secrets`, newly added or enlarged | |\n| + `extraFiles` or `extraEntries`, `extraInstallCommands`, and | |\n| + bootloader binary updates. Raise this if those writes can be | |\n| + large. | |\n| + ''; | |\n| + }; | |\n| + | |\n| + minGenerations = mkOption { | |\n| + default = 2; | |\n| + type = types.ints.positive; | |\n| + description = '' | |\n| + Keep at least this many of the newest generations even when the | |\n| + estimate says they exceed the budget. The copy may still | |\n| + succeed because the estimate is conservative; if the files | |\n| + genuinely cannot fit, installation fails with ENOSPC exactly as | |\n| + it does without this module. The default of 2 keeps the | |\n| + activated generation plus one rollback when possible. This | |\n| + floor is global across the | |\n| + default and named system profiles; it is not applied per profile. | |\n| + ''; | |\n| + }; | |\n| + }; | |\n| + | |\n| installDeviceTree = mkOption { | |\n| default = with config.hardware.deviceTree; enable && name != null; | |\n| defaultText = \"with config.hardware.deviceTree; enable && name != null\"; |", "url": "https://wpnews.pro/news/claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up", "canonical_source": "https://gist.github.com/ivan/6064b523d6a1461fae305cc82f289010", "published_at": "2026-07-27 04:07:56+00:00", "updated_at": "2026-07-28 02:32:09.141931+00:00", "lang": "en", "topics": ["developer-tools"], "entities": ["NixOS", "nixpkgs"], "alternates": {"html": "https://wpnews.pro/news/claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up", "markdown": "https://wpnews.pro/news/claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up.md", "text": "https://wpnews.pro/news/claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up.txt", "jsonld": "https://wpnews.pro/news/claude-fable-5-chatgpt-5-6-sol-pro-untested-nixpkgs-patch-to-avoid-filling-up.jsonld"}}