Back to Blog

Keeping a MacBook Awake With the Lid Closed

macossleeppmsetlaunchdcodesign

The short version: closing the lid is a separate sleep path from going idle, and none of the assertions caffeinate creates cover it. Keeping a MacBook running with the lid shut means setting SleepDisabled, a root-only setting that survives a reboot. That combination is why no one-line command does the job, and why the apps that do it ship a privileged helper.

You want a long download, a test run, or a compile to finish after you shut the laptop and walk away. You close the lid. The Mac sleeps anyway, and the job stops.

The advice you find online says to run caffeinate. It does not work, and the reason is worth knowing, because it explains what the tools that do work are actually doing.

Two sleeps, two mechanisms

macOS puts a Mac to sleep for reasons that have nothing to do with each other.

Idle sleep happens when nothing has happened for a while. Applications hold this off by taking power assertions, and caffeinate takes them on your behalf.

Clamshell sleep happens when you close the lid. That is a hardware event, and it takes a second path through the system. Power assertions do not reach it.

The manual is explicit about what each flag asserts:

Command Asserts Covers a closed lid
caffeinate -i the system will not idle sleep no
caffeinate -d the display will not idle sleep no
caffeinate -m the disk will not idle sleep no
caffeinate -s the system will not sleep, on AC power only no
sudo pmset -a disablesleep 1 clamshell sleep is off yes

So caffeinate answers the wrong question. It answers “the Mac is awake but nothing is using it.” Closing the lid asks “the lid is shut.”

You can watch the machine decide

pmset -g prints the current power state and names the processes holding it. This is the real output from the Mac I am typing on:

$ pmset -g
 sleep                5 (sleep prevented by coreaudiod, caffeinate, powerd)
 displaysleep         10 (display sleep prevented by Kipless)
 SleepDisabled        0

Two things to read there. The parenthesised lists name the assertion holders, which is how you find out what is keeping a Mac awake when you did not ask it to stay awake. The SleepDisabled line is the one that governs the lid.

SleepDisabled is stored, not held

An assertion belongs to a process. When the process exits, the assertion goes with it. That is a useful property: a crashed tool cannot leave your Mac permanently unable to sleep.

SleepDisabled works the other way. It is a stored setting.

  • Only root can change it.
  • It outlives a restart. Set it today, and a Mac that boots tomorrow still ignores the lid.
  • Nothing clears it when the process that set it dies.

That last point matters. A helper that sets SleepDisabled to 1, then crashes, then is unloaded on the next login, leaves a machine that never sleeps and an owner with no idea why. Whoever writes the helper has to take responsibility for the release path, because the system will not.

Doing it by hand is one command:

$ sudo pmset -a disablesleep 1
$ pmset -g | grep SleepDisabled
 SleepDisabled		1

Undoing it is the same command with a zero:

$ sudo pmset -a disablesleep 0

What an app has to do instead

An app cannot set this from its own process, because it does not run as root and should not. It has to delegate to something that does, and the supported way to do that is a LaunchDaemon registered through SMAppService.

The shape:

  1. Ship a small helper executable inside the app bundle.
  2. Register it with SMAppService.daemon at runtime.
  3. The user approves it once, in System Settings under General → Login Items.
  4. Talk to the daemon over XPC.

The interface design is where this goes right or wrong. The daemon runs as root, so whatever it accepts is code running as root. Give it a fixed list of operations rather than a command to execute. In Kipless the daemon accepts three: report the current state with /usr/bin/pmset -g, and set disablesleep to 1 or 0. There is no path from that interface to an arbitrary shell.

Three more things the lease has to get right, all of them learned from the version that got them wrong:

  • Do not clobber a setting you did not change. If SleepDisabled is already 1 when the session starts, the session does not own it and must not write 0 on release. Quitting the app should not silently defeat someone else’s configuration.
  • Read back after writing. pmset can fail without a useful exit code, and the only proof the setting took is reading it again.
  • Release the lease when the connection drops. XPC invalidation is the moment the daemon learns the app is gone. If nobody acts on it, the setting stays on with no owner.

The signing trap

This part has nothing to do with pmset, and it cost me a day.

The daemon registered, the user approved it, and it died the moment launchd tried to start it. The crash reports in /Library/Logs/DiagnosticReports/ said the same thing every time:

signal = SIGKILL (Code Signature Invalid)
termination.indicator = "Launch Constraint Violation"

The namespace on that violation was CODESIGNING, which pointed at the signature rather than the code. I assumed ad-hoc signing, which was wrong.

The answer was in the launch constraint itself:

$ launchctl print system/<label>
...
LWCR = { "reqs" => { "cdhash" => { "$in" => [ ] } } }

Read that requirement literally. The system was demanding that the daemon’s code hash be a member of an empty list. No binary can satisfy it, and no signature change would have helped. The record in the Background Task Management database had gone bad, and it was reapplied on every start attempt.

Registering the daemon again does recompute the constraint. That fix worked once and then failed again, because the recomputed requirement pinned a signing identifier that no longer matched the binary. I had changed the identifier between attempts without realising the system had already recorded the old one.

The rule that finally held: the label, the Mach service name, the plist filename, and the code signing identifier all have to be the same string.

Getting the identifier right has its own trap. codesign decides an identifier from the embedded Info.plist. When a tool has no Info.plist, it falls back to the filename and takes everything before the last dot. My helper was named com.kaoru.kipless.lidsleep, so it was signed with the identifier com.kaoru.kipless, which is the app’s own bundle identifier. The app and its root daemon were claiming the same identity. Giving the helper target an embedded Info.plist and an explicit PRODUCT_BUNDLE_IDENTIFIER ended that.

Two more constraints, both measured rather than assumed:

  • The executable has to live in Contents/MacOS. I had it in Contents/Resources, where launchd could not exec it at all. The constraint check never even ran.
  • It has to carry a Developer ID signature. The launch constraint carries validation-category = 6. An ad-hoc signature is a different category and fails it. Developer ID is required for distribution anyway, and here it is also what lets the daemon start on the developer’s own machine.

If you are debugging this yourself, launchctl print system/<label> and the crash report in DiagnosticReports will tell you more than any amount of reinstalling. Print the constraint and read the requirement literally.

Before you leave it running

A MacBook with the lid shut and no external display has nowhere to put its heat. The screen is off, the fans still have to move air through a closed chassis, and the machine sits on whatever surface you left it on. A closed-lid session on a bed is a worse idea than the same session on a desk.

Check the state before you walk away:

$ pmset -g | grep SleepDisabled
 SleepDisabled		1

Check it again when you come back, particularly if the app you used is no longer running.

Kipless

I built Kipless for this. It sits in the menu bar, offers three wake modes (system, display, and closed lid), and ends the session on a timer you set. The closed-lid mode holds an idle sleep assertion first, then asks the helper for the SleepDisabled lease, and rolls both back if either step fails. When the session ends, the setting returns to whatever it was before, including when it was already on.

It is open source under MPL-2.0, and brew install --cask ShiinaLabs/apps/kipless installs it.

Frequently asked questions

Can I keep my MacBook awake with the lid closed?

Yes, and not with `caffeinate`. Closing the lid takes a separate sleep path, and the only switch that covers it is `SleepDisabled`, which is root-only and survives a reboot. Set it by hand with `sudo pmset -a disablesleep 1`, or use an app that holds the setting for the length of a session and puts it back afterwards.

Does caffeinate keep a Mac awake with the lid closed?

No. `caffeinate` takes power assertions, and power assertions cover idle sleep. `caffeinate -s` prevents system sleep while the Mac is on AC power, and a closed lid still sends it to sleep. The manual lists what each flag asserts, and none of them mentions the lid.

How do I check whether sleep is disabled on my Mac?

Run `pmset -g` and read the `SleepDisabled` line. 0 means normal lid behavior. 1 means the Mac keeps running with the lid shut, and the setting survives a restart until something writes 0 back.

Is it safe to keep a MacBook running with the lid closed?

The machine has nowhere to put its heat once the lid is shut, and the chassis is sitting on whatever surface you left it on. Keep the session on a hard surface, keep it short, and use the mode that lets the screen turn off if you do not need it awake.

How do I undo closed-lid sleep prevention?

Run `sudo pmset -a disablesleep 0`. Check with `pmset -g` first: the setting outlives the process that changed it, so an app that crashed mid-session can leave a Mac that never sleeps and an owner who does not know why.