SwiftmacOSmTinker

What's Keeping My Mac Awake — From a Broken "Keep Awake" to a List of Culprits

mTinker's Keep Awake held one power assertion, so the display still went dark — the feature didn't match its own name. Fixing it meant reading the whole power-assertion table properly: AssertionTrueType is not AssertType, audio assertions are held by coreaudiod on someone else's behalf, daemons have to be identified by executable path rather than by name, and the presence of TimeoutSeconds decides whether an assertion ever lets go. The result shipped as "What's blocking sleep" in 1.7.0.

Your Mac won’t sleep. The fans run all night, and the battery is flat by morning. macOS offers no interface for finding out who is responsible — only pmset -g assertions, a screenful of mechanism jargon.

mTinker 1.7.0 adds “What’s blocking sleep,” which turns that table into one plain sentence and a button. This post covers how it reads the table — and the bug of my own I had to fix before building it.

Fixing my own mistake first

mTinker has had a “Keep Awake” toggle for a long time. It worked by holding a single IOKit power assertion:

IOPMAssertionCreateWithName(
    kIOPMAssertionTypePreventUserIdleSystemSleep as CFString,
    IOPMAssertionLevel(kIOPMAssertionLevelOn),
    "mTinker 保持 Mac 运行" as CFString,
    &assertionID
)

PreventUserIdleSystemSleep means “the system will not sleep from idleness.” It says nothing about the display, which keeps dimming and switching off on the system’s own schedule.

That was deliberate at the time. mTinker has a separate “Sleep Display” action, and I wanted “machine running, screen dark” to stay possible. Holding PreventUserIdleDisplaySleep forces the display on, which fights with that.

But Keep Awake reads, to anyone who isn’t me, as the screen stays on too. When the behaviour and the name disagree, that’s a bug — even when the code has a reason written next to it.

The fix is to hold the two assertions separately:

private var systemAssertionID: IOPMAssertionID = 0
private var displayAssertionID: IOPMAssertionID = 0

/// Whether the display assertion has been temporarily yielded for "Sleep Display" —
/// this is what decides whether to restore it on wake.
private var displayAssertionSuspended = false

Both are taken when the toggle goes on. Failing to get PreventUserIdleDisplaySleep is not treated as failure — the system assertion already holds, so the machine at least won’t sleep. A degraded feature beats a dead one.

“Sleep Display” then temporarily releases the display assertion to get out of the way, while the system assertion stays. “Machine running, screen dark” still works. When the display wakes, the assertion comes back:

NSWorkspace.shared.notificationCenter.addObserver(
    forName: NSWorkspace.screensDidWakeNotification, ...
) { _ in
    Task { @MainActor in
        KeepAwakeManager.shared.resumeDisplayAssertionIfNeeded()
    }
}

The two features stop being mutually exclusive, and the degradation lasts only until the next wake.

Assertion names have to be ASCII

Something I found on the way: the old assertion name was "mTinker 保持 Mac 运行" — Chinese characters. And pmset -g assertions renders non-ASCII assertion names as an empty string.

Which means that when a user reaches for the system tool to find out what’s keeping their Mac awake, mTinker itself shows up as an anonymous assertion. Now:

private static let systemAssertionName = "mTinker Keep Awake (system)"
private static let displayAssertionName = "mTinker Keep Awake (display)"

Your program appears in other people’s debugging sessions. Leaving a legible name there is basic courtesy — especially when you are about to ship a feature whose entire job is naming other people’s programs.

Reading the assertion table

The source is IOPMCopyAssertionsByProcess(), the table powerd maintains — the same data behind pmset -g assertions. Using the API rather than forking pmset and parsing its output means no subprocess and no dependency on a text format that can change.

var out: Unmanaged<CFDictionary>?
guard IOPMCopyAssertionsByProcess(&out) == kIOReturnSuccess,
      let byPID = out?.takeRetainedValue() as? [AnyHashable: Any] else { return [] }

You get “pid → array of that process’s assertions.” There are four traps in reading it.

One: there are two type fields, and you want the effective one

// AssertType is what the process declared; AssertionTrueType is what actually
// takes effect (legacy names get normalised into it).
let type = (entry[Key.trueType] as? String)
    ?? (entry[kIOPMAssertionTypeKey] as? String)

AssertType is what the process asked for. AssertionTrueType is what powerd resolved it to. Legacy spellings — NoIdleSleepAssertion and friends — get folded in at that step. Read only the declared type and you miss every process still using the old API.

The sets you match against need the legacy names too:

private static let systemSleepTypes: Set<String> = [
    kIOPMAssertionTypePreventUserIdleSystemSleep,
    kIOPMAssertionTypePreventSystemSleep,
    kIOPMAssertionTypeNoIdleSleep
]

private static let displaySleepTypes: Set<String> = [
    kIOPMAssertionTypePreventUserIdleDisplaySleep,
    kIOPMAssertionTypeNoDisplaySleep,
    "InternalPreventDisplaySleep"
]

That last one has no public constant. It’s powerd’s internal proxy assertion for deferred display sleep. A raw string literal in a type set is ugly, but omitting it leaves a whole class of “why won’t my screen turn off” unexplained.

Anything outside those two sets is skipped. UserIsActive — the user touched a key recently — must be skipped: it isn’t “a program is blocking sleep,” and keeping it means a permanent noise row.

Two: the holder is not the responsible party

Play audio on a Mac and the assertion keeping it awake is not filed under the player. It’s filed under coreaudiod. The system audio service holds the assertion on behalf of whoever is making sound.

Attribute strictly by pid and the user reads “coreaudiod is preventing sleep” — a process they neither recognise nor should be killing.

The entry carries AssertionOnBehalfOfPID, pointing at the party actually responsible:

var owner = raw.pid
var via: String? = nil
if let behalf = raw.onBehalfOfPID,
   behalf > 0,
   behalf != raw.pid,
   NSRunningApplication(processIdentifier: behalf) != nil {
    owner = behalf
    via = raw.processName   // the holder becomes the "held via" note
}

So the row shows the player’s name, annotated “held via coreaudiod.” Naming both the responsible party and the holder is what keeps the user from wondering why their music player appears as an unfamiliar daemon.

All three guards earn their place. behalf > 0 rejects garbage, behalf != raw.pid rejects self-reference, and the NSRunningApplication(processIdentifier:) check confirms that pid is still alive — assertions can outlive their process, and blaming an exited pid just renders a row with no name.

Three: daemons must be identified by path, not by name

The list has to separate three kinds: GUI apps (which you can ask to quit), the user’s own command-line processes (which you can signal), and system daemons (display only, no buttons).

My first instinct was a blocklist of process names. It doesn’t hold up, and the counter-example is sharingd (AirDrop / Handoff): it runs under the current user’s uid, and its name carries no system marker. uid can’t distinguish it; a name list would have to enumerate every case.

Identify by executable path instead:

private static let daemonPathPrefixes = [
    "/System/", "/usr/libexec/", "/usr/sbin/", "/sbin/", "/Library/Apple/"
]

There’s one deliberate omission: /usr/bin is not on the list. caffeinate, rsync and ffmpeg live there, they are the most common culprits by far, and the user started them — offering a “Quit” button for those is entirely reasonable. Treating /usr/bin as a system directory would turn the most actionable category into a read-only one.

Four: with or without TimeoutSeconds are two different things

Some assertions release themselves on a timer; some hold indefinitely. That distinction is exactly what tells the user whether to act, and the table has no field for it — you compute it:

var expiresAt: Date?
if let timeout = (entry[Key.timeoutSeconds] as? NSNumber)?.doubleValue, timeout > 0 {
    let base = (entry[Key.timeoutUpdate] as? Date) ?? since
    expiresAt = base.addingTimeInterval(timeout)
}

The base has to be AssertionTimeoutUpdateTime, not the creation time — timeouts can be renewed, and computing from creation yields an expiry that has already passed.

When aggregating up to the process, the semantics are worst case:

// One assertion without a timeout makes the whole process "never expires".
if raw.expiresAt == nil {
    agg.neverExpires = true
} else if let e = raw.expiresAt {
    agg.latestExpiry = max(agg.latestExpiry ?? e, e)
}

A process holding five assertions — four expiring in two minutes, one indefinite — is blocking indefinitely. Taking the longest timeout, or an average, would present a comforting expiry that never arrives.

Never-expiring rows sort first. Expiring ones read “releases in about 4 minutes,” and anything under a minute reads “releasing shortly” — otherwise you get “releases in about 0 minutes.”

Speak in consequences, not mechanisms

Technically this is a table of assertions, each with a type, a holder and a timeout. What the user wants is one sentence: what is happening right now, and do I need to do something about it.

So the grouping isn’t by assertion type. It’s by consequence:

  • “The screen won’t turn off”
  • “This Mac won’t sleep on its own”

Collapsed, there’s a single summary line, and it leads with the number that matters:

var summary: String {
    guard totalCount > 0 else { return loc("Nothing is blocking sleep") }
    let base = displayBlockers.isEmpty ? loc("This Mac won't sleep") : loc("The screen won't turn off")
    let pending = needsAttentionCount
    return pending > 0 ? loc("%@ · %d need you", base, pending) : loc("%@ · all release on their own", base)
}

“All release on their own” and “2 need you” are completely different states of mind. A summary that only says “4 programs are blocking sleep” still forces the user to expand and read every row to learn whether any of it matters.

Headless processes get a sentence of plain language, because caffeinate means nothing to most people:

private static let processNotes: [String: String] = [
    "caffeinate": "command-line keep-awake tool",
    "sshd": "someone is connected to this Mac over SSH",
    "rsync": "syncing files",
    "screencapture": "recording the screen",
    ...
]

The table only includes entries whose meaning is unambiguous. A confidently wrong explanation is worse than none.

One more: when mTinker’s own Keep Awake is on, it appears in the list, classified as selfApp. Building a feature that names other programs and then hiding yourself from it is the cheapest kind of dishonesty.

Two ways to end something

The “Quit” button exists only for GUI apps and the user’s own command-line processes — and the correct mechanism differs:

case .app:
    // terminate() runs the normal quit path, giving the app a chance to save
    show(app.terminate() ? loc("Asked %@ to quit", ...) : loc("%@ refused to quit", ...))
case .userProcess:
    // command-line processes have no such negotiation; send SIGTERM
    if kill(blocker.pid, SIGTERM) == 0 { ... }
    else if errno == EPERM { show(loc("Can't end %@: not permitted", ...)) }
    else { show(loc("Process %@ is no longer running", ...)) }

NSRunningApplication.terminate() goes through the normal quit sequence: the app can save, and it can also refuse. So the return value has to be reported honestly rather than optimistically claiming success. Command-line processes have no such protocol, and SIGTERM is the gentlest notice available.

A failing kill splits in two: EPERM means the process isn’t yours, and anything else generally means it already exited. Collapsing both into “couldn’t end it” makes the user think mTinker is broken.

Sampling runs only while the panel is visible, every 3 seconds, and stops on close. A diagnostic tool that is itself a battery drain is a special kind of stupid.

Three transferable things

When reading a system table, read the effective value, not the declared one. AssertType and AssertionTrueType coexisting isn’t redundancy — the latter is the normalised result. The same shape shows up across system APIs, and reading only the former silently drops every caller on the older interface.

Identify by path, not by name. Names are for humans: they collide, they can be imitated, they can coincide. The fact that sharingd runs a system daemon under the logged-in user’s uid is something only its install location can tell you.

Decide what your aggregate means. Folding several timeouts into one “does this ever let go” answer takes the worst case, not the longest — because the question the user is asking is “can I ignore this,” and a single non-expiring assertion makes the answer no.

And one for anything that holds a system resource: give your assertions, locks and handles an ASCII name a human can read. Someone will see it while debugging. mTinker spent a long time as an anonymous suspect in its own problem domain.

Comments

  • Loading…

Comments are reviewed before publishing; email is visible only to me.