niacin 1.7 — dropping caffeinate, going direct to IOKit
Since 1.0, niacin’s entire sleep-prevention engine has been a thin Swift wrapper around Process.run against /usr/bin/caffeinate. That was a deliberate “use Apple’s tested mechanism” decision and it served the app well for six releases. In 1.7 it’s gone. The engine now calls IOPMAssertionCreateWithName directly from Swift, with no child process involved. Same kernel primitive, ~30 lines of code instead of a subprocess lifecycle, and — this is the part that actually forced the change — no more orphaned caffeinates accumulating on your machine every time Sparkle delivers an update.
The bug that forced this
The caffeinate-subprocess engine had a real defect that only became visible once auto-updates landed in 1.6. When niacin exited abruptly — a crash, a force-quit, a kill -9, or, most relevantly, Sparkle relaunching the app to install an update — its child caffeinate process was reparented to launchd (PID 1) and kept holding its kernel power assertion forever. The system has no general way to clean those up: they’re owned processes, not leaked file descriptors, and launchd isn’t going to terminate something it inherited.
The developer’s machine accumulated roughly 40 orphan caffeinate processes over a day of repeated Sparkle test updates. Every one of them was still telling the kernel “don’t sleep,” and the Mac couldn’t. A clean reboot was the only way out. That’s the wrong failure mode for an app whose entire job is to be a well-behaved switch on top of caffeinate.
The fix is shorter than the bug report
caffeinate itself is open source — part of Apple’s PowerManagement project under the APSL — and it is, in essence, a ~200-line CLI wrapper around exactly one IOKit call: IOPMAssertionCreateWithName. There’s no real argument for shipping our own copy of it, and once you accept that, there’s no argument for keeping the subprocess at all. Going direct removes the leak class entirely, because IOKit power assertions are owned by the calling process. When niacin dies, the kernel releases them. That’s the contract.
The flag mapping is one-to-one with the surface niacin already exposed:
-i→kIOPMAssertionTypePreventUserIdleSystemSleep-di(Full Awake) → the above, pluskIOPMAssertionTypePreventUserIdleDisplaySleep-t N→ niacin’s ownTimer, which already existed for the countdown UI; it now also drives release of the assertion
No new user-facing behavior, no new settings, no menu changes. The Activate / Deactivate / duration picker all do exactly what they did in 1.6 — just without a child process.
Cleaning up after the old engine
The transition needs to mop up after itself for users upgrading from 1.0–1.6. On launch, niacin now sweeps any caffeinate processes parented to launchd (PID 1) — those are orphans from previous niacin versions or crashes, and they’re the ones still pinning the kernel awake. User-spawned caffeinate invocations from a Terminal session have a shell as their parent and are left strictly alone. From 1.7 forward niacin never spawns caffeinate at all, so this sweep is a one-time bootstrap and will come out in a future release.
Tests that verify the kernel, not the code path
Every release until now has had unit tests that exercised the engine’s code paths. None of them verified that the kernel was actually holding the assertion the code said it had requested. 1.7 closes that gap with five new SleepPreventerTests that shell out to pmset -g assertions and parse the output:
indefiniteFullAwakeHoldsBothAssertions— verifies bothPreventUserIdleSystemSleepandPreventUserIdleDisplaySleepare present inpmsetafter activation.allowDisplaySleepHoldsSystemAssertionOnly— same, but for-iequivalent mode: system assertion held, display assertion not.deactivateReleasesAssertions—pmsetconfirms both are released after Deactivate.timedActivationSetsActiveUntilWithinTolerance— the timer-driven release fires when it’s supposed to.reactivateReplacesPriorAssertions— reactivating doesn’t leak the previous assertion.
These are the first tests in the suite that observe a side-effect (the kernel actually holds the assertion) rather than just exercising the code that asked for it. 30 unit tests total, all green.
Side benefits worth naming
The leak fix is the reason for the change, but going direct picks up three things that mattered on the roadmap regardless:
- Immune to subprocess-execution policies. Some EDR / XDR products in enterprise environments block applications from spawning system binaries like
/usr/bin/caffeinate. niacin 1.7 makes the kernel call from its own address space; there’s noProcess.runfor any policy to intercept. - No
/usr/binpath dependency. One less assumption about the layout of the host system. - Sandbox-friendly. Niacin dropped the App Sandbox in 1.5 to make Sparkle work in
/Applications(the 1.6 post goes through that decision in detail), but if that ever needs to be reversed — e.g. for a future return to the Mac App Store — the engine is no longer a blocker.IOPMAssertionCreateWithNameis callable from sandboxed processes;Process.runagainst an absolute system path is not, in any clean way.
Upgrading
On 1.3 or later from a direct download? Sparkle will offer 1.7 the next time it polls the appcast, or right now via Settings → Software Update → Check Now. After install and relaunch the orphan-caffeinate sweep runs once; if your machine had accumulated any, pmset -g assertions will show them gone.
On 1.0–1.2 from a GitHub release? Grab the 1.7 .zip from the releases page, replace the app in /Applications, relaunch — from there you’re on the Sparkle auto-update track. Mac App Store users on 1.0–1.2 stay on that channel; the engine change is invisible from the outside, and MAS builds don’t carry the Sparkle plumbing anyway.
Source, EdDSA public key, and release notes are on GitHub at github.com/just-an-oldsalt/niacin. If your fleet sees an orphaned caffeinate the launch sweep doesn’t catch, that’s a bug — file it on the issue tracker or email niacin@dort.zone.