A macOS menu bar watchdog for the resource ceilings that take the whole machine down.
Some crashes aren't your app crashing. When macOS exhausts a system-wide limit — most often
the open-file table — launchd cannot get what it needs, dies, and the kernel panics with
initproc exited. Your Mac reboots instantly, mid-work, with no warning and no usable crash
report.
Handler shows how close you actually are, names the process eating the headroom, tells you which files it is holding, and can cut it off before the machine falls over.
◑ At risk — one process is dominating
Safe working limit: 370,334 open files (56% used)
Headroom: 2.9 more gopls-sized processes
At the current rate the table fills in 4h 51m
⚠︎ gopls alone holds 20% of the system-wide ceiling (96,610 of 491,520)
- Why a percentage isn't enough
- Install
- Using it
- Auto-reclaim
- The flight recorder
- What it measures
- How it works
- Troubleshooting
- Worked example
- Development
- Licence
The naive version of this tool watches kern.num_files / kern.maxfiles and turns red past
85%. That tool would have said "ok, 42%" on the machine this was written for, while it
was already in real danger.
The open-file table doesn't die from its current reading. It dies from concentration and from growth:
- Concentration. macOS lets a single process hold
kern.maxfilesperproc— typically half the system-wide total. One language server at 96,000 descriptors is 20% of the whole machine's budget, while every per-process check still reports "well within limits". - Growth. Sitting at 42% is harmless if it's flat and an emergency if it's climbing, because the thing that kills you is the next process to start.
So Handler computes a safe working limit:
safe limit = ceiling − reserve − (size of the current biggest holder)
The reserve (5%, minimum 20,000 descriptors) is headroom nobody should plan on using, so
launchd and the window server can still open files when everything else has gone wrong.
Subtracting the biggest holder answers the question that actually matters: if one more of
those starts, do I survive?
Alongside it, Handler reports headroom in units of the current worst offender ("room for 2.9 more gopls") and fits a least-squares trend over recorded history to turn growth into a deadline.
Any of five conditions raises the alarm:
| Signal | Default trigger |
|---|---|
| Raw usage | 50% notice · 70% warn · 85% critical |
| One process's share of the system ceiling | 8% · 15% · 25% |
| Headroom, in offender-sized slots | < 4 · < 2 · < 1 |
| Time to exhaustion at the current rate | < 30 min warn · < 10 min critical |
| Past the safe working limit | critical |
Download from Releases. Artifacts are universal binaries — Apple Silicon and Intel — and require macOS 13 or later.
# App
unzip Handler-0.0.1-macos-universal.zip
mv Handler.app /Applications/
# CLI
tar -xzf handler-0.0.1-macos-universal.tar.gz
sudo install -m 755 handler-0.0.1-macos-universal/handler /usr/local/bin/handlerVerify what you downloaded:
shasum -a 256 -c SHA256SUMSGatekeeper. Handler is ad-hoc signed but not notarised, so macOS will refuse the first launch of a downloaded build. Either right-click the app and choose Open, or:
xattr -dr com.apple.quarantine /Applications/Handler.appBuilding from source avoids this entirely.
git clone https://github.com/Don-Works/handler && cd handler
task install # /Applications/Handler.app + CLI on PATH
task install-agent # optional: start at login, keep alive
open /Applications/Handler.apptask install PREFIX=/usr/local if you'd rather not use the Homebrew prefix.
Without go-task: swift build -c release produces the binary, which
is both the CLI and the app.
To remove everything: task uninstall (recorded history is left in place).
The status item always shows a gauge and a number — the percentage of the safe working limit. A bare glyph in a crowded menu bar is invisible, and being noticed is the entire point. The needle and colour track severity: grey → amber → red.
The menu leads with a plain-language verdict and the reasons behind it, then the raw gauges, then the ten biggest descriptor holders. Each holder has a submenu:
- Inspect Open Files… — resolves every descriptor to a path and groups them, so
"96,608 descriptors" becomes "32,043 of them under
web/site/node_modules". - Quit … — with a confirmation, and blocked entirely for critical system processes.
The same binary is a CLI, which is what you want over SSH, in tmux, or in a script.
| Command | Does |
|---|---|
handler status |
Gauges, verdict, and why |
handler top [n] |
Biggest descriptor holders |
handler inspect [pid] [--depth N] |
Which files a process holds (defaults to the worst offender) |
handler watch [--guard] |
Live view; --guard enables auto-reclaim |
handler events |
What Handler has terminated on its own |
handler report |
Kernel panics, lined up against recorded history |
handler clean [--yes] |
Terminate known-restartable hogs now |
handler version |
Version |
Off by default. When enabled — menu item, or --guard on the CLI — Handler terminates
the biggest descriptor hog once the situation is critical: when there is no longer room
for another process the size of the current worst offender. The next session to start would
have been the one that killed the machine.
It is deliberately narrow:
- only processes on the restarts-cleanly allowlist — language servers and file watchers,
where holding an index or a watch is the process's whole job and whatever started it will
transparently start it again. Build, test and search tools are deliberately excluded:
killing
go testor a runningtscdestroys in-flight work, and they don't accumulate descriptors the way a long-lived watcher does; - never below PID 100, never anything on the protected list (
launchd,WindowServer,loginwindow, …); - one process per 60-second cooldown, then re-evaluate;
- every action is notified and appended to
guard-events.jsonl. Automatic action is never silent —handler eventsshows the log.
Everything else is a warning. A monitor that starts killing things on its own initiative is worse than the problem it solves, so the aggressive path is opt-in and stays on a leash.
A kernel panic gives you no chance to save anything, so Handler writes as it goes:
- every 30 seconds when calm, every 5 seconds once any gauge is elevated;
fsync'd immediately — an unflushed buffer is worthless after a hard reset;- JSONL in
~/Library/Application Support/Handler/history/, pruned after 14 days.
After a crash, handler report finds the panic in your diagnostic reports, decodes the exit
reason (namespace 2 subcode 0xa → "launchd, killed by SIGBUS"), and prints the gauge trace
from the fifteen minutes leading into it — the thing the panic log itself cannot tell you,
because the kernel often cannot even collect a stackshot before it dies.
| Gauge | Source | Notes |
|---|---|---|
| Open files | kern.num_files / kern.maxfiles |
The one that kills machines. Counts every process, including ones you can't inspect. |
| Processes | proc_listallpids / kern.maxproc |
|
| Threads | summed PROC_PIDTASKINFO / kern.num_threads |
Best-effort; excludes processes owned by other users. |
| Memory | host_statistics64 |
"Free" counts free, inactive and purgeable pages. |
| Swap | vm.swapusage |
Informational — macOS grows swap on demand; size alone is not a fault. |
| Vnode cache | kern.num_vnodes / kern.maxvnodes |
Informational — sits at 100% on every healthy Mac. It is a cache, not a leak. |
Two are deliberately informational and never raise the alert level. A gauge that is always red teaches you to ignore the icon.
No lsof. Everything comes from sysctl and libproc. lsof stats every mount, so a
single unresponsive SMB or AFP share hangs it for minutes — and a machine in trouble is
exactly when that happens. Handler uses proc_listallpids + proc_pidinfo(PROC_PIDLISTFDS),
which never touches the filesystem: a full 800-process scan takes about 1 ms, and
handler status runs end to end in 10 ms.
Descriptor paths (handler inspect) come from PROC_PIDFDVNODEPATHINFO, resolved on demand
rather than during routine sampling, because that part is proportional to the descriptor
count.
Partial data is reported as partial. Descriptors held by processes owned by other users
are unreadable without root. Handler says how many processes it could not inspect rather than
counting them as zero — and because the headline gauge is a system-wide kernel counter, the
alarm stays correct even when the attribution is incomplete. Run the CLI under sudo for a
full per-process picture.
No sandbox, no entitlements, no network. Handler reads kernel counters and sends signals to processes you own. It makes no network connections of any kind.
I can't see the icon. If you run a menu bar manager — Ice, Bartender, Hidden Bar — new items often land on the hidden side of the divider. Expand it and drag Handler across; its position is saved. On a crowded bar without a manager, ⌘-drag it left.
The vnode cache is at 100%. That is normal and Handler marks it informational. macOS fills the vnode cache and then recycles entries; it is not a leak and it will never be anything but full on a machine that has been up for a while.
status says N processes aren't inspectable. Those are owned by root or another user.
sudo handler status sees everything. The system-wide gauges are unaffected either way.
Auto-reclaim didn't fire. It only acts at critical, only on the restarts-cleanly
allowlist, and only once per minute. Check handler events and handler status — if the
biggest holder is something like a browser or a VM, Handler will warn but will not touch it.
A panic isn't in handler report. Reports are read from
/Library/Logs/DiagnosticReports and ~/Library/Logs/DiagnosticReports, last 30 days. If
the crash predates your install, the run-up trace will be empty — the recorder can only show
what it was running for.
A Mac kept force-rebooting mid-work. The panic log said:
panic(cpu 3): initproc exited -- exit reason namespace 2 subcode 0xa
That is launchd killed by SIGBUS, and the kernel could not collect a stackshot — so
nothing pointed at a cause. handler inspect found it in under a second:
gopls (pid 36395) — 96,611 descriptors, 96,600 resolve to files
32,820 ~/project/mobile/ios/App/…
32,043 ~/project/web/site/node_modules/…
19,422 ~/project/web/chat/node_modules/…
By type: .js 26,549 .ts 8,834 .map 8,636 .pcm 6,105
One gopls holding 96,600 descriptors — kqueue watches, which require one open descriptor
per watched file, over node_modules and Xcode build output rather than Go source. Every
editor and agent session started another one. Five of them exhausts a 491,520 ceiling, every
open() begins to fail, launchd is not exempt, and the machine dies.
Killing two of them took the system from 208,069 open files to 15,166.
Fixes for that case, in descending order of bluntness: scope the language server to the Go module rather than the monorepo root; exclude build output from its watch set; let auto-reclaim cull the extras.
task # list every task
task build # release binary for this machine
task build-universal # arm64 + x86_64
task smoke # exercise every read-only subcommand
task status # current verdict
task release-artifacts # universal archives + checksums into dist/
task release VERSION=0.0.1No package dependencies, by design. Contributions welcome — see CONTRIBUTING.md for the ground rules that keep it trustworthy.
AGPL-3.0-or-later. Copyright © 2026 Don Works. See NOTICE.