Raise macOS file-descriptor / vnode kernel ceilings so a long-running or concurrent Apple Virtualization.framework VM (VirtioFS file sharing) doesn't exhaust the host's file descriptors.
A VM built on Apple's Virtualization.framework that bind-mounts a host directory over VirtioFS keeps one host file descriptor open per file the guest touches through that share — and does not release them until the VM is torn down. A big dependency/package store (tens of thousands of small files) makes this add up fast: the host's FD and vnode counts climb over the life of a session, and a long-lived or multi-VM workload walks into the kernel caps and runs the host out of descriptors.
This raises those caps. It's a mitigation — descriptors still only free on VM teardown — but with plenty of RAM the ceilings are cheap to raise generously.
maxvnodes is the co-limit most people miss. A file descriptor is per-open;
a vnode is per-file, shared by every FD pointing at the same file. So N VMs
opening the same store cost N× the FDs but only 1× the vnodes — vnode demand
tracks the number of distinct files touched. A single heavy VM can brush both
the per-process FD cap and the vnode cap at once, so raising maxfilesperproc
alone barely helps: vnodes become the new wall. They have to move together.
Defaults measured on Tahoe (macOS 26.5) → target:
| sysctl | default | target | × | role |
|---|---|---|---|---|
kern.maxfilesperproc |
245,760 | 491,520 | 2× | single-process open-FD cap (one VM helper) |
kern.maxvnodes |
263,168 | 1,048,576 | ~4× | distinct-file cap — the co-limit most people miss |
kern.maxfiles |
491,520 | 3,932,160 | 8× | system-wide FD cap (headroom for ~8 maxed VMs) |
Values are tuned generously for a machine with plenty of RAM (e.g. 64 GB); revisit
the multipliers for a smaller host. launchctl limit maxfiles (soft 256) is
deliberately left alone — the VM helper self-raises its own rlimit well above
that, so the kernel caps above are the real walls.
sudo ./install.shIdempotent. It installs fd-limits.sh → /usr/local/sbin/ (root-owned, so
launchd will run it), installs the LaunchDaemon → /Library/LaunchDaemons/,
applies the ceilings immediately, then loads the daemon. The runtime sysctls reset
on reboot; the daemon (RunAtLoad) re-applies them at every boot, logging to
/var/log/fd-limits.log. (install.sh / uninstall.sh also append their own
output to a local, gitignored log.)
The live sysctl values are the source of truth — a daemon showing "loaded" only means it's registered to re-apply at boot, not that its last run succeeded.
sysctl kern.maxfiles kern.maxfilesperproc kern.maxvnodes # <- the real check
sudo launchctl print system/io.github.jonahbraun.macos-fd-raise-limit | grep -i state
grep achieved /var/log/fd-limits.log | tail -1 # what the last boot actually setfd-limits.sh logs both the requested and read-back achieved values and exits
non-zero if they differ, so a partial apply (e.g. a future macOS rejecting one
key) is visible even though the daemon still reports "loaded".
sudo ./uninstall.sh # remove daemon + script (sysctls revert on reboot)
sudo ./uninstall.sh --revert # ...and reset the live sysctls to defaults now| file | purpose |
|---|---|
install.sh |
idempotent installer (run with sudo) |
uninstall.sh |
remove daemon + script (--revert to reset sysctls) |
fd-limits.sh |
the worker: sets the three sysctls; installed to /usr/local/sbin/, run at boot |
io.github.jonahbraun.macos-fd-raise-limit.plist |
LaunchDaemon that runs the worker at every boot |
log |
local run log — install.sh / uninstall.sh append their output here each time they run (gitignored, created on first run) |