An overnight archive job stops halfway through, leaving only a timeout record the next morning. After reconnecting through remote desktop, the Mac itself works normally. This type of failure should not automatically be blamed on the network. For unattended Cloud Mac CI, first distinguish among system sleep, display sleep, remote session termination, and Runner process termination. These four states may look similar, but each requires a fix at a different layer.
Identify the Layer Where the Interruption Occurs
Before changing any settings, record the failed job’s last output, exit code, and host boot time. If the log stops abruptly in the middle of a build command while the boot time remains unchanged, investigate sleep behavior and process lifecycle. If the boot time has changed, treat the incident as a reboot rather than masking it with sleep-prevention settings.
| Symptom | Check first | Likely conclusion |
|---|---|---|
| Display turns off, but SSH and the build continue normally | displaysleep |
Only the display output is sleeping |
| SSH and the job become unreachable at the same time | sleep, power logs |
The system may have entered sleep |
| The job exits when the terminal is closed | Runner startup method | The process is attached to an interactive session |
| Host boot time changes | Reboot history, job logs | A system reboot occurred |
Start by collecting a baseline without changing the system:
mkdir -p "$HOME/ci-audit"
date > "$HOME/ci-audit/power-baseline.txt"
pmset -g custom >> "$HOME/ci-audit/power-baseline.txt"
pmset -g assertions >> "$HOME/ci-audit/power-baseline.txt"
pmset -g sched >> "$HOME/ci-audit/power-baseline.txt"
sysctl -n kern.boottime >> "$HOME/ci-audit/power-baseline.txt"
pmset -g custom shows settings for different power-source profiles, pmset -g assertions lists processes currently preventing sleep, and pmset -g sched reveals existing scheduled power events. Do not draw a conclusion from a single sleep 0 line. Review both the applicable power profile and the assertions currently in effect.
Separate Display Sleep from System Sleep
A Mac mini has no battery, so the AC power profile is the primary concern. Display sleep does not stop compilation, and CI does not require a virtual display output to remain permanently active. What matters for unattended jobs is whether the system sleeps and whether the Runner continues to exist as a valid process.
On a dedicated node used continuously for builds, you can save the original output and then disable system sleep while connected to AC power:
sudo pmset -c sleep 0 disksleep 0 powernap 0
pmset -g custom
This does not change displaysleep, because whether the screen turns off is unrelated to a background build. After applying the command, read the settings again instead of treating a successful command exit as proof that the change took effect. If the Mac also supports interactive desktop work, evaluate the team’s usage schedule first. For infrequent CI jobs, preventing sleep only while a job runs is preferable to permanently changing the system policy.
Power settings determine whether the Mac stays awake. They cannot turn a temporary process attached to a terminal into a background service.
Wrap Each Build with caffeinate
caffeinate can create power assertions for as long as its child process remains alive. Compared with disabling sleep globally, this approach has a clearly defined scope and is particularly suitable for scheduled archives, long-running tests, and one-off dependency builds.
Create a shared wrapper script:
sudo install -d -m 0755 /usr/local/bin
cat <<'EOF' | sudo tee /usr/local/bin/ci-awake >/dev/null
#!/bin/zsh
set -euo pipefail
if (( $# == 0 )); then
exit 64
fi
exec /usr/bin/caffeinate -ims "$@"
EOF
sudo chmod 0755 /usr/local/bin/ci-awake
Do not append another background operator in the Runner. Let the wrapper directly take over the target command:
/usr/local/bin/ci-awake /usr/bin/xcodebuild \
-workspace App.xcworkspace \
-scheme App \
-configuration Release \
-destination 'generic/platform=iOS' \
build
-i prevents idle system sleep, -m prevents idle disk sleep, and -s prevents system sleep while on AC power. When the target command exits, the assertion is released with the wrapper process. Run pmset -g assertions during the build to verify that the corresponding assertion is present.
Detach the Runner from the Remote Session
If the build still exits when SSH or the graphical session is closed, the Runner’s parent process—not pmset—is the primary concern. A process started manually in a terminal may receive a session termination signal. It may also fail when its working directory, environment variables, or temporary keychain context disappears.
A persistent Runner should be managed by launchd. Verify at least the following:
- Use a fixed service label such as
local.ci.runner, and do not start another instance after every login. - Specify the working directory with an absolute path rather than relying on the terminal’s current directory.
- Define
PATH, toolchain selection, and cache directories explicitly instead of inheriting interactive shell configuration. - Write standard output and standard error to separate log files, with a controlled rotation policy.
- Ensure that the Runner terminates its current child process when it receives an exit signal, preventing orphaned processes from retaining the build directory.
Check a per-user service as follows:
launchctl print "gui/$(id -u)/local.ci.runner"
pgrep -afil 'runner|xcodebuild'
If the service must run while no user is logged in, use a system-level LaunchDaemon and inspect it with launchctl print system/local.ci.runner. Do not keep both user-level and system-level instances active, or two Runners may contend for the same working directory.
Verify the Deployment with Evidence
After completing the configuration, schedule a test job that runs longer than the duration at which previous interruptions occurred. Deliberately close the remote desktop and SSH clients during the test, but do not stop the Runner. After reconnecting, verify the job exit code, artifact timestamps, service process, and power assertions.
If another interruption occurs, immediately collect:
date
pmset -g assertions
pmset -g log | tail -n 120
sysctl -n kern.boottime
last reboot | head
Final acceptance should satisfy four conditions: display sleep does not affect the job; the Runner remains active after remote sessions are closed; the caffeinate assertion is visible while the build is running; and the assertion is released automatically when the job finishes. If any condition fails, return to the corresponding layer instead of continually stacking additional power settings.
For a dedicated Cloud Mac on XcodeVM, reliable unattended CI depends on two clear boundaries: system power policy keeps the node running, while launchd and the job wrapper keep the build process independent of interactive sessions. Configuring and gathering evidence for these layers separately ensures that the next interruption leaves a diagnosable cause rather than a vague timeout record.
Frequently asked questions
Does disabling display sleep prevent the Mac from sleeping?
No. displaysleep only controls the display. Check the system sleep value and active power assertions separately with pmset before relying on the machine for unattended CI.
Should a CI Mac have sleep disabled permanently?
That is reasonable for a physical node dedicated to continuous CI after recording its original settings. For occasional jobs, caffeinate keeps the no-sleep assertion scoped to one command.
Why does a build stop after the remote session disconnects?
The Runner may still be attached to an interactive shell. Run it as an independent launchd service, then inspect its exit code, power logs, and reboot history.
XcodeVM macOS Cloud Hosts
Choose a dedicated physical machine suited to your current workload
Review the memory, storage, node, and billing cycle before proceeding to checkout. All nodes run continuously year-round; actual availability is based on the real-time status returned by the console.