DEV Community

Cover image for Eight Rows Were Blocking Sleep. None Survive a Closed Lid.
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Eight Rows Were Blocking Sleep. None Survive a Closed Lid.

A week ago I opened my laptop's power log and counted how many times it woke in seven days. That piece ended with a question left hanging: counting the wakes was easy, but who was keeping the machine from going to sleep in the first place? There was a command at the end of that log that would tell me, and I had never run it.

This morning I ran it. I expected a single culprit — one row saying "this app won't let you sleep." What came back was a stack of eight rows. One of them was the operating system itself, one was the app I'm writing this in, and one was a twitch from the mouse on my desk. And after reading each of them, the thing that emerged was the opposite of what most people expect from this list.

What was on the list

The command is pmset -g assertions. Its output has two halves: per-type counters on top, and below them the list of whoever is holding those types. Here is the snapshot I took at 08:37:19 on 5 October 2026 (macOS 26.6.2, arm64, plugged in, battery at 82% and charging):

Assertion status system-wide:        (six of nine counters; three zero rows omitted)
   BackgroundTask                 0
   UserIsActive                   1
   PreventUserIdleDisplaySleep    0
   PreventSystemSleep             0
   PreventUserIdleSystemSleep     1
   NetworkClientActive            0
Listed by owning process:            (assertion ids shortened)
   pid 737(useractivityd): 00:00:01 PreventUserIdleSystemSleep named: "BTLEAdvertisement.7B3D0F64-..."
    Timeout will fire in 59 secs Action=TimeoutActionTurnOff
   pid 5820(Claude): 21:42:48 NoIdleSleepAssertion named: "Electron"
   pid 551(mds_stores): 00:00:01 BackgroundTask named: "com.apple.metadata.mds_stores.power"
   pid 413(coreaudiod): 00:18:10 PreventUserIdleSystemSleep named: "com.apple.audio...preventuseridlesleep"
    Created for PID: 55144.
   pid 400(WindowServer): 00:00:00 UserIsActive named: "...product:Razer Cobra Pro eventType:17"
    Timeout will fire in 1800 secs Action=TimeoutActionRelease
   pid 340(powerd): 00:47:32 PreventUserIdleSystemSleep named: "Powerd - Prevent sleep while display is on"
   pid 55097(Google Chrome): 00:10:12 NoIdleSleepAssertion named: "Playing audio"
   pid 693(sharingd): 00:08:46 PreventUserIdleSystemSleep named: "Handoff"
Enter fullscreen mode Exit fullscreen mode

Eight processes. The durations in the third column tell you how long each claim has been standing — from a few seconds to twenty-two hours.

The first warning is right here: this list is not an inventory, it is a photograph. I ran the command seven times at four-second intervals; the number of process rows moved between seven and nine. Short-lived assertions are born and die while you are reading the output.

When the counter says "1", it usually means the screen is on

The second-longest row on the list belongs to powerd itself: Powerd - Prevent sleep while display is on. It had been standing for forty-seven minutes, and its name states honestly what it does — as long as the display is on, it prevents the system from going into idle sleep.

That changed how I read the counters. Seeing PreventUserIdleSystemSleep 1 and concluding "some app won't let me sleep" is wrong; most of the time that "1" is simply the screen being on — and not an app at all, but powerd itself.

This needs care, because I conflated two separate things on my first reading. Apple's header has a sentence saying that while the display is prevented from dimming, the system cannot go into idle sleep — but that sentence describes kIOPMAssertPreventUserIdleDisplaySleep, and in my snapshot that counter reads 0. The row here is not that one; it is a separate idle-sleep assertion powerd takes in its own name. The two produce the same outcome, but they are not the same thing.

The counters tell you even less when you line them up. What is behind UserIsActive 1? A cursor twitch that WindowServer picked up from a Razer Cobra Pro. So the source of the "user is active" claim is not me, it is a tremor from my mouse. It stands with a thirty-minute timeout and will be released on its own when that expires.

The process you read the name of is not the one holding sleep

The coreaudiod row had been up for eighteen minutes. Is coreaudiod the culprit? The two lines beneath it say something else:

    Created for PID: 55144.
    Resources: audio-out 40-B6-E7-BF-45-E2:output
Enter fullscreen mode Exit fullscreen mode

The assertion is registered under coreaudiod but was created on behalf of process 55144. The audio stack raised its hand as a proxy for whichever app was playing sound.

I saw the same pattern on runningboardd. Three of the seven snapshots I took at four-second intervals carried this row:

   pid 407(runningboardd): 00:00:03 PreventUserIdleSystemSleep named:
      "app<application.net.whatsapp.WhatsApp...>407-78849-352524:
       Shared Background Assertion 4734 for net.whatsapp.WhatsApp(FinishTask)"
    Created for PID: 78849.
Enter fullscreen mode Exit fullscreen mode

The name at the head of the row is runningboardd. The real owner is written in two places at once: in the app identifier embedded inside the assertion's name, and as the bare number on the Created for PID line. A three-second background task, on behalf of a messaging app.

The practical consequence: quitting the first name you see on this list often means quitting the wrong app. If there is a Created for PID line, the real owner is there; if there isn't, the process is speaking for itself. A three-line detail, but it is the only way to lay the blame at the right door.

Twenty-two hours, and a name abandoned in 2011

The longest-standing row on the list concerns me directly: pid 5820(Claude), a NoIdleSleepAssertion approaching twenty-two hours, named simply "Electron". The application I am writing this in has not lowered its hand once since yesterday midday. Black boxes get through their most stubborn nights without telling anyone.

My first reaction was to call it a defect. Because the name NoIdleSleepAssertion is plainly old in Apple's header: kIOPMAssertionTypeNoIdleSleep, deprecated in 10.7, with kIOPMAssertPreventUserIdleSystemSleep recommended in its place. Google Chrome's "Playing audio" row uses the same old name. Two major applications were holding my machine awake with an API name abandoned fifteen years ago.

Then I looked at powerd's source and refuted my own claim. In PMAssertions.c, while kPreventIdleType is being set up, both names are written to the same index:

case kPreventIdleType:
    idxRef = CFNumberCreate(0, kCFNumberIntType, &idx);
    CFDictionarySetValue(gUserAssertionTypesDict, kIOPMAssertionTypePreventUserIdleSystemSleep, idxRef);
    CFDictionarySetValue(gUserAssertionTypesDict, kIOPMAssertionTypeNoIdleSleep, idxRef);
Enter fullscreen mode Exit fullscreen mode

The old name is an alias. The behaviour is exactly the same type. So the "legacy API" observation here is cosmetic: you see two different names in the output, and inside the system there is one type. The source code is what stopped me from writing "major apps are using a dead API" — and that is a good illustration of the limits of diagnosing from the outside.

The question that matters in a bag: which sleep, on which power source

Everything so far is interesting, but what I actually wanted to know was this: do these eight rows keep my laptop awake in a bag with the lid closed? That is the question for anyone who has met a warm machine and a drained battery in a backpack.

The answer sits in a single sentence in Apple's own header. For kIOPMAssertPreventUserIdleSystemSleep:

"The system may still sleep for lid close, Apple menu, low battery, or other sleep reasons."

This type only prevents sleep that arrives because of idleness. It does not prevent lid close, the sleep command from the Apple menu, or low-battery sleep. The same file adds one more thing a sentence later: this assertion has no effect while the system is in Dark Wake — meaning the list carries no weight at all inside those 160 dark wakes I counted in the previous piece.

There is a separate type that prevents sleep arriving on demand: PreventSystemSleep. In the source its effect goes by the name kPrevDemandSlpEffect. But in those same lines it carries another flag:

case kPreventSleepType:
    ...
    assertType->flags |= kAssertionTypeNotValidOnBatt | kAssertionTypePreventAppSleep
        | kAssertionTypeLogOnCreate;
Enter fullscreen mode Exit fullscreen mode

kAssertionTypeNotValidOnBatt. And that flag is not decoration in the source; powerd enforces it in several places by checking the live power source:

if ( (type->flags & kAssertionTypeNotValidOnBatt) && ( _getPowerSource() == kBatteryPowered) )
Enter fullscreen mode Exit fullscreen mode

The caffeinate manual page says the same thing in user language: for the -s flag, "This assertion is valid only when system is running on AC power."

So the picture closes like this: on battery, with the lid closed, not one row on this list keeps your machine awake. The types that block idle sleep do not interfere with lid close; the type that can interfere with lid close is invalid on battery. Looking for the cause of a laptop warming up in a bag on this list means leafing through the wrong ledger.

All of that is document reading. The good part is this: the measurement of the same thing was sitting in my own log, and I only noticed it while fact-checking this piece. Claude's twenty-two-hour assertion was created around 11:00 on 4 October and has stood unbroken since. While it stood, in the middle of the night:

2026-10-05 04:33:27 +0300 Sleep  Entering Sleep state due to 'Clamshell Sleep':
    TCPKeepAlive=active Using Batt (Charge:11%) 968 secs
Enter fullscreen mode Exit fullscreen mode

Lid closed, battery at 11%, and the machine slept. There are 82 Clamshell events in the seven-day log. So "these rows do not apply once the lid shuts" is not a document quotation for me; it is something measured that night. I had the same evidence in hand before I read the header file, and I could not see it — because I was asking the wrong question.

Diagram

I added a row to the list with my own hands

To understand how assertions look, I created one myself. caffeinate -d -t 12 prevents display sleep for twelve seconds. Before it, the counter was zero:

PreventUserIdleDisplaySleep    0
Enter fullscreen mode Exit fullscreen mode

While the command ran:

PreventUserIdleDisplaySleep    1
   pid 70349(caffeinate): 00:00:04 PreventUserIdleDisplaySleep named: "caffeinate command-line tool"
    Details: caffeinate asserting for 12 secs
    Localized=THE CAFFEINATE TOOL IS PREVENTING SLEEP.
Enter fullscreen mode Exit fullscreen mode

As expected. What I did not expect happened after the command finished. The counter returned to zero, but a new row appeared in the list:

InternalPreventDisplaySleep    1
Enter fullscreen mode Exit fullscreen mode

This is powerd's own assertion. To establish causality I repeated the experiment and opened the log immediately afterwards; three lines tie together in the same second:

09:01:06 Assertions  PID 70349(caffeinate) Created  PreventUserIdleDisplaySleep "caffeinate command-line tool" 00:00:00
09:01:18 Assertions  PID 70349(caffeinate) TimedOut PreventUserIdleDisplaySleep "caffeinate command-line tool" 00:00:12
09:01:18 Assertions  PID 340(powerd)       TurnedOn InternalPreventDisplaySleep "com.apple.powermanagement.delayDisplayOff" 00:00:00
Enter fullscreen mode Exit fullscreen mode

My assertion times out at twelve seconds and powerd opens its own row in that very second. So releasing an assertion does not return the system to its previous state immediately; the operating system inserts its own breathing space.

Two small details surfaced here too. My assertion ended with TimedOut, not Released — because I had given it a duration with -t. And powerd's began with TurnedOn, not Created; that line is an already-existing assertion being switched back on. The counter list is not a fixed length either: InternalPreventDisplaySleep was absent from the output before the experiment and present in the one after. If you set out to count and compare the rows in the output, you will be talking to two different tables on the same machine.

One of the log's verbs is not an event

When I moved on to the assertion lines inside pmset -g log, another counting trap turned up. The log does not use a single verb; I counted eight of them in my seven-day file:

29869 Released     498 TimedOut      9 CapExpired
22174 Created      281 TurnedOn      1 ClientDied
 2991 Summary      252 TurnedOff
Enter fullscreen mode Exit fullscreen mode

Seven of those are real events. One is not — Summary lines are periodic photographs, all printed side by side under a single timestamp:

08:29:53 Assertions PID 693(sharingd) Summary PreventUserIdleSystemSleep "Handoff" 00:01:20
08:29:53 Assertions PID 55097(Google Chrome) Summary NoIdleSleepAssertion "Playing audio" 00:02:46
08:29:53 Assertions PID 340(powerd) Summary PreventUserIdleSystemSleep "Powerd - Prevent..." 00:40:06
08:29:53 Assertions PID 5820(Claude) Summary NoIdleSleepAssertion "Electron" 21:35:22
Enter fullscreen mode Exit fullscreen mode

If you run grep Assertions | wc -l, you will have counted every assertion standing at that moment one extra time. That fallacy has a sibling: the very end of pmset -g log is not history, it is the live assertion dump of that moment. The block you read as "the last lines of the log" is not log at all.

Once I had the verbs sorted out, I found something interesting in the log. PowerUIAgent keeps creating a maintenance-wake assertion named com.apple.obc and releasing it within the same second. 3,647 times in seven days.

At first glance I took it for a tidy staircase; it isn't. Of the 3,646 intervals, 1,191 are zero seconds — more than one event in the same second — and 3,161 are under forty seconds. But there are steady climbs in between. The longest strictly increasing run is nine steps, and I count twenty-two runs longer than five steps. One of the nines looks like this:

80, 81, 83, 96, 105, 123, 143, 175, 181
Enter fullscreen mode Exit fullscreen mode

The climbs peak between 181 and 206 seconds, then the counter resets and starts over. It resembles a backoff-retry pattern. But to be honest, I could not verify what obc is by reading from the outside — I suspect the name points to something charging-related, since the machine was charging at the time, but as I could not trace that in the source I am leaving it as a guess. Measuring a pattern and naming it are not the same thing.

I got the last two words in the bracket wrong

At the end of every assertion line in the log there is a system state in square brackets:

[System: PrevIdle DeclUser SRPrevSleep IntPrevDisp kCPU kDisp]
Enter fullscreen mode Exit fullscreen mode

I went looking for where these abbreviations are produced; the printAggregateAssertionsToBuf function in PMAssertionLog.c writes each one out. Entries like PrevIdle, PrevSleep, DeclUser and IntPrevDisp come from the current level of process assertions. The last two come from somewhere else:

if (kbits & kIOPMDriverAssertionCPUBit) {
    printed += strlcat(aBuf, " kCPU", bufsize);
}
if (kbits & kIOPMDriverAssertionPreventDisplaySleepBit) {
    printed += strlcat(aBuf, " kDisp", bufsize);
}
Enter fullscreen mode Exit fullscreen mode

kCPU and kDisp are driver assertion bits — they come from the kernel side, not from the level of process assertions.

Here I rushed and drew the wrong conclusion: "so these have nothing to do with applications." They do. The same source file spells out how powerd translates process assertions into kernel bits:

case kInteractivePushServiceType:
case kPreventSleepType:
case kBackgroundTaskType:
...
    assertBit = kIOPMDriverAssertionCPUBit;
    break;

case kDeclareUserActivityType:
case kPreventDisplaySleepType:
case kIntPreventDisplaySleepType:
    assertBit = kIOPMDriverAssertionPreventDisplaySleepBit;
Enter fullscreen mode Exit fullscreen mode

So kCPU is most often raised on behalf of some process's assertion. In my own log the two are born and die in the same second: when PowerUIAgent creates its assertion, kCPU appears in the bracket; when it releases, the word disappears.

The layer that really is independent of applications sits at the very bottom of the output, under its own heading:

Kernel Assertions: 0x104=USB,MAGICWAKE
   id=566  level=255 0x100=MAGICWAKE  description=en0  owner=IOSkywalkNetworkBSDClient
   id=799  level=255 0x4=USB  owner=USB3.1 Hub
   id=801  level=255 0x4=USB  owner=USB2.1 Hub
   id=803  level=255 0x4=USB  owner=USB 10/100/1000 LAN
   id=805  level=255 0x4=USB  owner=USB Receiver
   id=807  level=255 0x4=USB  owner=USB2.0 Hub
   id=809  level=255 0x4=USB  owner=Razer Cobra Pro
Enter fullscreen mode Exit fullscreen mode

Six USB devices and one network interface. Note the total at the top: 0x104 is made of the 0x100 (MAGICWAKE) and 0x4 (USB) bits — in the kernel header kCPU is 0x01 and kDisp is 0x40. So this section is not showing those two words from the bracket; both were off at that moment. This is an entirely different ledger: the hubs on my desk, the network adapter, a receiver and the mouse. There is no app you can quit for these rows; short of unplugging a cable, you have no intervention. This is exactly the layer missed by everyone who assumes the thing preventing sleep must be "an app".

I saw one more thing: the two halves of the output do not always agree. In the snapshot above, the BackgroundTask counter reads 0, yet a row of exactly that type is sitting in the list — mds_stores and its one-second assertion. It was not yet born when the counter was computed, and born by the time the list was printed. I could not catch it again across seven later samples, so it is not a standing inconsistency but a narrow race window. It is still enough to say this: even the output of a single command does not belong to a single instant.

Test it yourself with two commands

You do not have to take the two boldest sentences in this piece on trust; both can be tested on your own machine in seconds.

For the "that 1 is really the screen" claim, put the display to sleep by hand and look at the list again straight away:

pmset displaysleepnow && sleep 2 && pmset -g assertions | head -12
Enter fullscreen mode Exit fullscreen mode

powerd's "Prevent sleep while display is on" row should be gone. For the "the type that blocks lid-close sleep is invalid on battery" claim, unplug and try this:

caffeinate -s -t 10 & sleep 3; pmset -g assertions | grep -E 'PreventSystemSleep|caffeinate'
Enter fullscreen mode Exit fullscreen mode

The PreventSystemSleep counter goes to 1 while plugged in — tested on this machine. On battery it should not; that is what the NotValidOnBatt flag and the manual page say. But that half was never measured for this piece: you be the one who unplugs and looks.

The rest is a reading habit: look at the long-standing rows (hours, not minutes); if a row has a Created for PID line beneath it, the culprit is the process there, not the one in the heading; if your worry is the battery in a bag, this is the wrong ledger and you want the sleep/wake log; and read the Kernel Assertions section once — my list had six USB devices on it, and I could not have solved any of them by quitting an app.

The answer wasn't where I looked; it was in my own log

I started writing this to find a name. What came out was not a name but a distinction: the right question is not "who is preventing sleep" but which sleep, on which power source. Look at the list without drawing that line and all eight rows look guilty; draw it and most of them were never my business.

But here is what actually nags at me. I learned the sentence "on battery, with the lid closed, these rows carry no weight" from Apple's header file. Meanwhile the measurement of the very same thing had been sitting in my own log for a week: a lid closing at 04:33 in the morning, battery at 11%, with a twenty-two-hour assertion standing. The record was in my hands and I heard the sentence from someone else.

The moment a measurement is most useful is not when it gives you the answer — it is when it shows you that you asked your own data the wrong question. Ever since the evening the disk promised me 60 GiB and held 14, I have been learning the same lesson from different counters, and the repetition itself is probably the lesson.

Official Sources

Top comments (0)