Skip to content

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Handler

A macOS menu bar watchdog for the resource ceilings that take the whole machine down.

License: AGPL v3 Platform

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)

Contents


Why a percentage isn't enough

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

Install

From a release

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/handler

Verify what you downloaded:

shasum -a 256 -c SHA256SUMS

Gatekeeper. 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.app

Building from source avoids this entirely.

From source

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.app

task 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).

Using it

Menu bar

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.

CLI

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

Auto-reclaim

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 test or a running tsc destroys 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 events shows 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.

The flight recorder

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.

What it measures

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.

How it works

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.

Troubleshooting

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.

Worked example: the bug this was built 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.

Development

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.1

No package dependencies, by design. Contributions welcome — see CONTRIBUTING.md for the ground rules that keep it trustworthy.

Licence

AGPL-3.0-or-later. Copyright © 2026 Don Works. See NOTICE.

About

macOS menu bar watchdog for the resource ceilings that panic your Mac — open-file exhaustion, per-process attribution, opt-in auto-reclaim. Part of Don Works (open source by Revitt).

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages