Skip to content
cphotorPublic

About

Sleep and wake handling for a MacBookPro12,1 on Linux: network shares, Wi-Fi and the Thunderbolt Ethernet adapter are taken off before sleep and put back after; sleeps wait for running backups. One daemon, one systemd hook, no kernel module.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

13 Commits

Folders and files

Repository files navigation

mbp-sleep

Sleep and wake handling for a MacBook Pro (Retina, 13-inch, Early 2015 — MacBookPro12,1) on Linux. It makes the machine sleep when asked, wake when asked, and keep its network afterwards.

It is one small daemon plus one systemd sleep hook and one configuration file. No kernel module, no patched kernel.

The problems it solves

Most of these share one pattern: something was still attached when the machine went to sleep. Taking it off before sleep — and putting it back afterwards — is what the program does, and it is what makes the cycles complete. The wake-source row is the exception: there, the firmware itself leaves a source disabled.

symptom what is happening what this program does
It never finishes going to sleep. The screen goes black and the machine sits there. A network share with unwritten data whose server is gone blocks sync_filesystem(). Measured in this machine's journal: 194.646 s and 95.554 s in Filesystems sync, against 0.014–0.031 s once the share was unmounted first. Unmounts network filesystems before sleep and remounts them after.
Suspend is aborted and the machine stays awake. The journal says PM: suspend entry, then -EIO. The Wi-Fi driver was not in a state where it could be suspended; the failure of one device aborts the whole transaction. Disconnects Wi-Fi and unloads the driver before sleep, reloads it after resume. A driver that is not loaded cannot refuse.
It sleeps but never comes back. It is powered, but the screen stays dark and no key does anything. On this machine the device that made the difference was the Thunderbolt Ethernet adapter: with it attached across sleep, resumes did not complete; taken off the bus before sleep, they do. The detailed resume-path explanation this project used to give is not re-verifiable from the journals it still has — treat it as a hypothesis, not a finding. De-authorises the adapter's own Thunderbolt endpoint before sleep and re-authorises it on resume. The Thunderbolt host controller is never touched, so the adapter keeps working.
It wakes, but Wi-Fi or network shares are gone. The driver came back without a usable radio, or the shares were unmounted on the way in and nothing put them back. Reloads the Wi-Fi driver, waits for the interface, and re-mounts the shares with retries until the network is up.
A backup is killed by a sleep, or a sleep requested during a backup is lost. The desktop suspends in the middle of the job. Holds a logind block lock while a backup runs, remembers the suspend request, and issues it again when the backup ends.
Keyboard and lid cannot wake the machine. /proc/acpi/wakeup is rebuilt at every boot, and this firmware declares the internal keyboard/touchpad source (SPIT) disabled. Re-arms the configured wake sources at daemon start. It never disables one.
It wakes by itself with the lid shut and stays awake. The wake source is not identified, and nothing puts the machine back to sleep: logind's lid handling is off here, and a lid watch that acts only on a fresh close sees no change. Measured 2026-10-23: a self-wake at 23:22 left it awake, lid shut, for 38 hours. On resume, if the lid is still closed, suspends again after a settle delay — at most a few times in a row. Also logs which wake counters moved, so the source can be hunted.

If the machine wakes by itself a few seconds after sleeping, this program does not suppress the wake: the source is not identified, and the one candidate that was investigated — the EC's GPE 0x4E — was masked and the machine still woke, then the claim was retracted; see docs/background.md. What it does suppress is the consequence: with resuspend = on, a resume that finds the lid still closed sends the machine back to sleep (resuspend_delay), at most resuspend_max times in a row, and wake_log records the delta of /sys/class/wakeup/*/event_count and the ACPI GPE counters so the next wake has a candidate.

Which machines

Written and tested on a MacBookPro12,1 (13-inch Early 2015). The Wi-Fi, Thunderbolt and wake-source parts are Apple-specific:

part applies to
brcmfmac suspend refusal, Wi-Fi reload Apple laptops with a Broadcom Wi-Fi card
Thunderbolt Ethernet endpoint the Apple Thunderbolt→Gigabit Ethernet adapter; the mechanism is the same for other Thunderbolt NICs
SPIT wake source Macs whose firmware declares the internal keyboard source disabled
network filesystems, backup deferral, report, lid handling any laptop running systemd — these parts are not Apple-specific

Every behaviour is a switch in one configuration file, so the parts that do not apply to your machine can be turned off.

Requirements

  • Linux with systemd (tested with systemd 262) and Python 3.10 or newer.
  • NetworkManager — only for the Wi-Fi detach (nmcli).
  • dbus-monitor — only for noticing a suspend request made during a backup.
  • polkit — only for the optional "do not offer to force a suspend past the lock" rule.
  • util-linux, kmod, systemd: mount, umount, modprobe, systemctl, journalctl.

Nothing is needed from the desktop environment.

Install

Needs root; the script re-executes itself through pkexec if you are not root.

git clone git@github.com:cphotor/mbp-sleep.git
cd mbp-sleep
sudo ./install.sh          # or: pkexec ./install.sh

It installs:

path what
/usr/local/sbin/mbp-sleep the program
/usr/local/share/mbp-sleep/VERSION the version, copied from VERSION in this repository
/etc/systemd/system/mbp-sleep.service the daemon (wake sources, lid, backup lock)
/usr/lib/systemd/system-sleep/50-mbp-sleep the sleep hook (this systemd reads only /usr/lib/...)
/etc/systemd/logind.conf.d/50-mbp-sleep.conf logind ignores the lid, so this program handles it
/etc/systemd/system/systemd-suspend.service.d/10-no-freeze.conf user sessions are not frozen before sleep (freeze_sessions)
/etc/mbp-sleep.conf the configuration, only if it does not exist yet
/etc/polkit-1/rules.d/49-mbp-sleep-no-force-suspend.rules generated when silence_force_prompt = on

Anything it replaces is copied to /var/backups/mbp-sleep/<timestamp>/ with a manifest.tsv, so uninstall.sh can put it back. An existing /etc/mbp-sleep.conf is never overwritten; if the shipped defaults have gained a switch, the installer prints which ones are missing.

The version has one source: the VERSION file in this repository. install.sh copies it, mbp-sleep --version reads it, and a release is the tag v$(cat VERSION).

Check that it works

mbp-sleep status       # switches in effect, wake sources, Wi-Fi, adapter, mounts
mbp-sleep dry-run      # what the next suspend would do, without doing it

Then suspend — from the menu, the lid, or the key — and wake it again, and ask:

mbp-sleep report --boot 0        # --boot -1 for the previous boot

It answers, for every cycle in that boot: did it sleep, where did it stop if not, and was it a wake. The wake source itself is not in the journal — take a /sys/class/wakeup/*/event_count snapshot around a run for that. If a cycle goes wrong, that output is the thing to attach to a report.

Configuration

The live file is /etc/mbp-sleep.conf. macbook.conf in this repository is the same file with every option commented — it is the reference for the defaults, and any key you delete falls back to the default compiled into the program, so a partial file is not a broken file.

Format: key = value. # starts a comment. Blank lines are ignored.

When an edit takes effect

keys read by to apply an edit
wake_sources, wake_devices, defer_on_backup, backup_names, defer_max_wait, silence_force_prompt, lid_watch, lid_state, resuspend, resuspend_delay, resuspend_max the daemon, once at start sudo systemctl restart mbp-sleep
wifi_*, thunderbolt_*, cifs_*, network_fstypes, wake_log the sleep hook, on every sleep nothing — the next suspend uses it
freeze_sessions install.sh re-run sudo ./install.sh

Editing macbook.conf in the repository changes nothing on the machine; it is a template for the first install.

Wake sources

key default meaning
wake_sources on re-apply the wake-source state at every daemon start
wake_devices SPIT XHC1 LID0 the ACPI devices to keep enabled: internal keyboard/EC, USB, lid

Wi-Fi

key default meaning
wifi_detach on disconnect and unload the driver before sleeping
wifi_interface wlan0 interface to disconnect
wifi_modules brcmfmac_wcc brcmfmac modules to unload, in this order (reversed, brcmfmac reports "in use")
wifi_restore_wait 20 seconds to wait for the interface to reconnect after the driver is loaded

Thunderbolt Ethernet

key default meaning
thunderbolt_eject on de-authorise the adapter's endpoint before sleep, re-authorise on resume. The host controller 0-0 is skipped by name — de-authorising it removes the whole Thunderbolt router
thunderbolt_name Thunderbolt to Gigabit Ethernet Adapter product string used to find the endpoint
thunderbolt_settle 2 seconds to wait before re-authorising

Network filesystems

key default meaning
cifs_unmount on unmount network filesystems before sleep, remount after
cifs_auto on discover mounts from /proc/mounts by type — no share names needed anywhere
network_fstypes cifs smb3 nfs nfs4 fuse.sshfs which filesystem types count as "network"
cifs_shares (empty) only read when cifs_auto = off: share names under cifs_root
cifs_root /mnt prefix for cifs_shares
cifs_umount_wait 15 seconds per mount before giving up; a lazy unmount is never used
cifs_reconnect_wait 120 seconds to keep retrying after resume — the network is not up when the hook runs

An idle x-systemd.automount share is invisible to this: its mount point carries autofs, not cifs, so it is left alone and comes back the next time something touches it.

What to remount is decided before the first unmount, not after the last one: pre writes the shares it is about to take down to /run/mbp-sleep/network-unmounted, and post restores that list and nothing else. A non-zero umount is not believed either — the mount table is asked again, because on this machine umount of a CIFS share is routinely killed at the 15 s timeout after the kernel has already dropped the mount. A share that is recorded and does not come back stays recorded, so the next sleep tries it again, and status prints it as was unmounted for sleep, not back yet.

scripts/test-cifs-record.py exercises that whole decision against a made-up mount table: no root, no share, and it reproduces the exit code that caused the 2026-10-25 miss (docs/config-changes.md, C44).

Backups

key default meaning
defer_on_backup on hold a logind block lock while a backup runs
backup_names borg program names that count as a backup, matched against argv
defer_max_wait 3600 safety valve: a backup older than this is treated as hung, the lock is released and the machine may sleep
silence_force_prompt on write the polkit rule that denies org.freedesktop.login1.suspend-ignore-inhibit, so the desktop does not offer a password to force a suspend past the lock

A suspend requested while the lock is held is held, not lost: it is remembered and re-issued when the backup ends, so the machine suspends by itself. To suspend immediately, stop the lock: sudo systemctl stop mbp-sleep.

Lid and session freeze

key default meaning
lid_watch on handle the lid here, because logind is told to ignore it
lid_state /proc/acpi/button/lid/LID0/state where the lid state is read
freeze_sessions off off installs the drop-in that sets SYSTEMD_SLEEP_FREEZE_USER_SESSIONS=false

Self-wake

The wake source on this machine is not identified; see docs/background.md and the retracted GPE 0x4E diagnosis. The wake itself is therefore not suppressed. Its consequence is.

key default meaning
resuspend on a resume that finds the lid still closed is sent back to sleep — opening the lid is what wakes this machine, so a closed lid at resume means nobody came back
resuspend_delay 10 seconds to settle before re-suspending
resuspend_max 3 consecutive re-suspends before it gives up and stays awake, so a wake/suspend loop cannot run forever
wake_log on pre saves /sys/class/wakeup/*/event_count and the ACPI GPE counters; post logs what moved (wake: counters moved: …)

A sleep longer than five minutes starts the counter again, and opening the lid clears it, so the cap only trips on a real loop. A deliberate test that resumes with the lid closed (scripts/test-suspend.sh) is re-suspended too — open the lid first, or set resuspend = off.

wake_log is a candidate finder, not a conclusion: the Apple EC block moves its counters on every cycle no matter who woke the machine. Use event_count, never wakeup_count — the latter only moves on a retried suspend and stays 0 on a clean S3 cycle.

Commands

command what it does
mbp-sleep status switches in effect, wake sources, Wi-Fi, adapter, mounts, whether a backup is running and whether a suspend is being deferred
mbp-sleep dry-run print every pre-suspend and post-resume step without performing it
mbp-sleep report --boot N the four questions, for every cycle in boot N (-1 = previous boot)
mbp-sleep cifs-reconnect wait for the network and remount the shares taken down before sleep
mbp-sleep wake re-apply the wake sources
mbp-sleep --version print the version from VERSION
mbp-sleep pre / post what the sleep hook calls; not for interactive use

Run them with sudo when they need /sys, /proc/acpi/wakeup or the mount table.

Uninstall

sudo ./uninstall.sh

Stops and disables the service, removes the unit, the hook, the program, the two drop-ins, the configuration and the generated polkit rule, reloads logind so the lid works normally again, and restores what install.sh moved aside. To restore a specific backup:

sudo ./uninstall.sh --restore /var/backups/mbp-sleep/20261020-180946

The polkit rule is removed only if it carries the generated by mbp-sleep marker, so a rule written by hand is never touched.

If it still fails

  1. mbp-sleep status — is the daemon running, are the switches what you expect, is the adapter/interface/mount the one you think?
  2. mbp-sleep dry-run — does the plan match what you expect?
  3. mbp-sleep report --boot 0 — did the machine actually sleep, and if not, which step was last?
  4. Turn off what does not apply: wifi_detach, thunderbolt_eject, cifs_unmount are independent switches. A machine without a Thunderbolt adapter should keep thunderbolt_eject = off.
  5. The journal is the source of truth: journalctl -b -o short-monotonic | grep -i -E 'PM: suspend|Restarting tasks|Filesystems sync'.

Hibernation is not supported. Nothing here runs for hibernate.target, and hibernation does not work on this hardware as configured.

How it works

  • The sleep hook (/usr/lib/systemd/system-sleep/50-mbp-sleep) runs mbp-sleep pre before suspend(2) and mbp-sleep post after resume. It never fails the sleep: a sleep that could not be prepared is still better than a machine that refuses to sleep.
  • The daemon owns the long-lived state: wake sources, the lid, and the logind block lock that defers sleep during a backup.
  • It writes nothing outside /run/mbp-sleep/, /var/backups/mbp-sleep/ and the generated polkit rule.

More

  • docs/background.md — why these failures happen, what was measured, and what is not worth re-testing on this hardware.
  • docs/config-changes.md — every change this project made to the machine it was developed on, with an undo.
  • AGENTS.md — the rules used when working on this repository.

License

MIT — see LICENSE. Copyright (c) 2026 cphotor.

About

Sleep and wake handling for a MacBookPro12,1 on Linux: network shares, Wi-Fi and the Thunderbolt Ethernet adapter are taken off before sleep and put back after; sleeps wait for running backups. One daemon, one systemd hook, no kernel module.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages