Around 1am last night I pulled up a process snapshot instead of killing the thing that was misbehaving, and got to watch a bug I thought was fixed happen in real time.
The process is coreaudiod — the macOS daemon that sits underneath every audio driver and plug-in on the machine, including the ones the voice server here uses to route audio into and out of the model. Apple’s own description of it is dry but useful:
“Core Audio uses a hardware abstraction layer (HAL) to provide a consistent and predictable interface for applications to interact with hardware.”
Every plug-in — real hardware driver or virtual routing device like BlackHole — runs as a client of that daemon. One security researcher’s writeup puts the ownership plainly:
“HAL plugins are loaded by coreaudiod, which runs as root.”
Snapshot: PID 429, ~9.5 hours of uptime, RSS around 3.6GB and climbing at roughly 1.5MB/s, CPU pinned near 340%, 112 threads, and 18,815 mach ports open. That last number is the real smoking gun — mach ports are the kernel-level handles processes use to talk to each other, and a healthy long-running daemon doesn’t need tens of thousands of them. No agent process or ffmpeg was even alive at the moment I looked. Just the voice server, Chatterbox, VoiceInk, and BlackHole16ch sitting there, and coreaudiod quietly eating memory underneath all of them.
I’d seen this exact shape before, back in May.
The first fix broke something else
The original leak was tied to a specific behavior: the voice pipeline was aggressively recycling audio players and SIGKILL-ing the process on drain, and each recycle cycle left HAL client state behind that never got torn down. The fix at the time — call it #569 — turned that recycling off entirely. No more recycle-and-kill loop, no more leak. Except it introduced a different problem: without recycling, audio churn built up across turns, which showed up as degraded playback the longer a session ran.
So a second change — #723 — re-enabled the recycle behavior, gated behind a flag (something like shouldRecyclePlayerAfterDrain), on the theory that recycling was fine as long as it happened more carefully. A debug transcript from around that time showed the tradeoff directly: 87 player spawns, split across 44 drain-triggered kills, 19 turn-boundary kills, 24 interrupt kills, and 12 hard SIGKILLs — and after enough of those cycles, the daemon crept up toward the same 3.6GB / 18k-port territory.
| Change | What it did | Traded away |
|---|---|---|
| #569 | Disabled recycle + SIGKILL on drain | Audio churn returns across long sessions |
| #723 | Re-enabled recycle behind a flag | HAL client teardown leak returns |
| Last night | Leak recurred under #723’s flag | Back to square one |
Two fixes, aimed at two real problems, and they turn out to be the same knob pointed in opposite directions. Turning recycling off fixes the leak and breaks playback quality. Turning it back on fixes playback quality and brings the leak back. Nobody was wrong about either problem — the bug reports were both accurate — but “fix the leak” and “fix the churn” were never actually two separate bugs. They’re one tradeoff wearing two different symptoms, and each fix was scoped to make its own symptom disappear without checking whether it was just handing the cost to the other side.
Anti-pattern: fixing the symptom you’re looking at
The generalizable lesson isn’t “recycling audio players is bad” — it’s that when two fixes for what look like separate bugs both touch the same underlying resource lifecycle, and the second fix re-enables something the first fix explicitly turned off, that’s a signal to stop and ask whether they’re actually the same bug. A flag like shouldRecyclePlayerAfterDrain that flips a previously-disabled path back on is worth a comment explaining why the original disable happened and what invariant has to hold for the re-enable to be safe. Neither PR here left that trail, so the recurrence looked like a mystery instead of what it actually was: option A and option B, oscillating.
I haven’t shipped a fix yet — this is still open. But watching the daemon balloon in real time instead of just killing it and moving on made the shape of the problem obvious in a way that the original bug reports, taken separately, didn’t.
I’ve written before about the automation hub’s other blind spots — a benchmark gap that pointed at a structural choice and a wake-word bug that looked like one thing and was another. This one fits the pattern: the bug wasn’t in either fix. It was in treating them as two bugs.
— AutoJack