Gremlins After Midnight: How 794 Git Packs Hit the macOS 256 File Limit
A routine darwin-rebuild on Determinate Nix 3.15 died with 'Too many open files' because Nix's flake cache is a libgit2 repo, it had grown to hundreds of packs, and macOS still ships a 256 file descriptor limit to anything launchd starts.
This was on Determinate Nix 3.15.2 (Nix 2.33.1). If you are on something newer, the specific failure below may not reproduce: Nix 2.34 and Determinate Nix 3.16 both started raising the soft descriptor limit at process startup, which defuses this exact class of crash. The mechanics are still worth understanding, because the underlying ingredients have not changed.
I ran darwin-rebuild switch the way I have a thousand times. Same flake, same machine, nothing exotic in the diff. It had worked that morning. This time it fell over before it built a single derivation:
error: opening Git repository "~/.cache/nix/tarball-cache-v2": could not open '~/.cache/nix/tarball-cache-v2/config': Too many open files
Too many open files. On a rebuild that touches my own config. Nothing was leaking, nothing was in a loop. The machine had simply run out of something, and the something was file descriptors.
What a file descriptor limit even is
Every open file, socket, and pipe in a Unix process is a file descriptor: a small integer that the kernel hands back so you can refer to it later. There is a cap on how many a single process may hold open at once, and on macOS there are two of them. The soft limit is what a process gets by default, and it can be raised on its own up to the hard limit. The hard limit is the ceiling, and only the root can lift it.
You can read both from a shell:
$ ulimit -Sn; ulimit -Hn
1048576
unlimited
So my interactive shell was fine: a million descriptors soft, no hard ceiling. That high number is not a macOS default, though. A stock login shell on macOS also starts at 256; something in my environment had raised it long ago, and I had forgotten. If the rebuild had run from this shell, it would have inherited the raised limit and never noticed. Here is the catch: not everything runs from your shell.
macOS keeps a third number, the one launchd uses, and that is the one that bites. launchd is the system supervisor, and it seeds the limits for anything it starts: daemons, agents, and any process spawned by something that did not bother to raise its own soft limit. Ask launchd what it thinks:
$ launchctl limit maxfiles
maxfiles 256 unlimited
There it is. 256. In 2026, a fresh macOS still imposes a soft limit of 256 open files on new processes, a number that would have felt generous on a workstation in 1995. Whatever raised my shell's limit never touched launchd, so the low number sat out of sight. Anything launchd starts, rather than my shell, does not get the favor.
Why libgit2 opens so many at once
The path in the error is a clue most people skip past. ~/.cache/nix/tarball-cache-v2 is not a plain directory of downloaded tarballs. It is a Git repository. Nix keeps its flake and tarball inputs in a libgit2-backed store, which is why the failure is literally "opening Git repository" and the file it choked on is config, the first thing any Git repo reads on open.
libgit2 is the C library Nix links against to talk to that store. A pack comes in pairs: a .pack with the compressed objects and a .idx with the lookup table. To resolve objects, libgit2 mmaps pack files and keeps them mapped, so the descriptors it holds scale with how many packs are in the object store. There is a configurable cap on this (GIT_OPT_SET_MWINDOW_FILE_LIMIT), but the point that matters here is the direction: more packs means more descriptors held open while the cache is in use, and opening or indexing a cache full of packs is exactly when that count spikes.
Wait. Why would there be enough packs for that to matter? A healthy repo has a handful.
Why the cache grows a forest of packs
A Git pack is normally the product of garbage collection: many loose objects compacted into one tidy file. But Nix's tarball cache is not gc'd on a schedule the way a working repo is. As it fetches flake inputs and tarball revisions, it writes packs, and nothing ever runs maintenance to fold them back together. They just accumulate for as long as the cache lives.
I counted mine:
$ du -sh ~/.cache/nix/tarball-cache-v2
306M ~/.cache/nix/tarball-cache-v2
$ find ~/.cache/nix/tarball-cache-v2/objects/pack -type f | wc -l
1593
1593 files in the pack directory: .idx and .pack paired up, plus a few stragglers, so on the order of 790-odd packs. Against a soft limit of 256, a cache this size does not need anything exotic to go wrong; the descriptors libgit2 holds while working through the packs are enough to walk a 256-limit process off the edge. The cache had quietly grown a forest, and the rebuild was the first thing to walk into it from a process that inherited launchd's stingy 256 instead of my shell's million.
Two correct decisions composed into one broken rebuild. Nix never compacts the cache because it is a cache, and compaction is not free. macOS ships a tiny soft limit because it always has, and your shell hides it.
The immediate fix: collapse the packs
The fastest way out is to give libgit2 fewer files to open. The instinct is to reach for git gc --aggressive --prune=now, and do not: this cache has no refs of its own (git -C ~/.cache/nix/tarball-cache-v2 show-ref comes back empty), so every object is unreachable from gc's point of view. --prune=now then happily discards the cached inputs you were trying to keep. You end up with a tidy repo and an empty cache.
The safe way to compact in place is the multi-pack-index, which folds the packs without pruning objects out from under Nix:
git -C ~/.cache/nix/tarball-cache-v2 multi-pack-index write
git -C ~/.cache/nix/tarball-cache-v2 multi-pack-index repack
git -C ~/.cache/nix/tarball-cache-v2 multi-pack-index expire
That collapses the pack count, reducing the descriptors libgit2 needs to a handful while leaving the actual objects intact. The rebuild stops the limit from tripping because the limit is no longer the binding constraint.
If you do not care about keeping the cached inputs, there is an even blunter option. It is a cache, so you are allowed to throw it away:
rm -rf ~/.cache/nix/tarball-cache-v2
Nix rebuilds it clean on the next fetch. The repack is the nicer move because you keep everything already downloaded, but both clear the immediate error. I repacked.
The durable fix: tell launchd to stop being from 1995
Repacking buys you time. It does not stop the packs from accumulating again, and it does nothing about the actual root cause, which is that 256 soft limit waiting to ambush the next process launchd spawns. The real fix is to raise launchd's maxfiles so it survives reboots and applies to invokers that never see your shell rc.
For the running session, raise it directly:
sudo launchctl limit maxfiles 524288 524288
That sets both the soft and hard limit to 524288 until the next reboot. To make it stick, drop a launch daemon at /Library/LaunchDaemons/limit.maxfiles.plist that re-applies the same limit every boot:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>limit.maxfiles</string>
<key>ProgramArguments</key>
<array>
<string>launchctl</string>
<string>limit</string>
<string>maxfiles</string>
<string>524288</string>
<string>524288</string>
</array>
<key>RunAtLoad</key>
<true/>
</dict>
</plist>
It runs one command at load, launchctl limit maxfiles 524288 524288, and exits. After a reboot, launchctl limit maxfiles reports 524288 524288 instead of the factory 256, and every process launchd starts inherits the saner ceiling.
It is worth a look at what the Nix daemon itself already does, because it solved this for its own process years ago. The systems.determinate.nix-daemon plist sets SoftResourceLimits and HardResourceLimits with NumberOfFiles at 1048576. The daemon was never the thing hitting the wall. The thing hitting the wall was whatever spawned the nix invocation for the rebuild, inheriting 256 because nobody told launchd otherwise.
Where it landed
After the repack the rebuild went through on the first try. After the plist, launchctl limit maxfiles shows 524288 across the board, so a future pack forest will have room to breathe even from a non-shell invoker. I still expect to repack that cache again someday, because Nix will keep minting packs and never folding them, but it will not take the rebuild down with it.
The takeaway I am keeping: when a tool dies with "Too many open files" on macOS, do not trust ulimit in your shell. Your shell may show a comfortable number that launchd never agreed to. Ask launchctl limit maxfiles what the process actually inherited, because that is the limit that was really in force.