Switching to the terminal brought up the beachball, and nothing happened after that. The process was alive, its threads looked idle, and ps said it was sleeping. It was not sleeping. The kernel had suspended it to stop the Mac from running out of memory, and then nothing had ever let it go.
The short versionWhen macOS runs out of room for compressed and swapped memory, the XNU kernel suspends the app holding the most compressed memory and puts up the "Your system has run out of application memory" dialog. The suspension is a Mach task hold, not SIGSTOP, so ps still shows the app as sleeping and the only trace is a kernel flag. Resume in the Force Quit window is the intended way back. When that does not stick, kill -CONT releases the hold, and one short pipeline resumes every paused app at once.
A terminal that would not wake up
The terminal was Ghostty, open for about two weeks with sixteen tabs. It had been in the background for a while. Bringing it forward with open -a Ghostty made it the active app as far as LaunchServices was concerned, but the window never responded, and a second attempt failed outright with error -1712, an Apple Event timeout. AppleScript requests timed out the same way.
The first checks all said the process was fine:
pslisted it asS, sleeping, rather thanT, stopped.- A two-second
sampleshowed every thread parked in its normal wait. The main thread sat in the AppKit event loop waiting for the next event, and the renderer and I/O threads sat inkevent64andpoll. No loop, no deadlock, no lock contention.
Two other numbers did not fit a healthy app:
- Its CPU time did not change by a single hundredth of a second over eight seconds, including the seconds right after it was brought to the front. An app that receives events always does some work.
- Its resident memory was about 2.5 MB, against a physical footprint of 878 MB and a peak of 1.3 GB. Almost all of it had been compressed or written to swap.
A process that is idle, responsive and entirely paged out looks like that. So does a process that cannot run at all. The way to tell them apart turned out to be a signal.
What the kernel does when swap runs out
The cause is memory, not CPU. A busy machine usually means many large processes at once, and that is what drives memory into the compressor and then into swap, but the kernel only reacts to the memory side. macOS has no hard "out of memory" wall. It compresses memory that has not been touched recently, and when the compressor fills it writes compressed segments to swap files in /System/Volumes/VM. When the compressor is close to its limit, or the kernel cannot create another swap file, memorystatus runs what XNU calls the no paging space action. The function is short:
compressor near its limit, or no new swap file
|
v
+-----------------------------------+ yes +---------------------+
| one process without a policy |------>| kill it |
| holds over 50% of compressed mem? | +---------------------+
+-----------------------------------+
| no
v
+-----------------------------------+ +---------------------+
| largest compressed process that |------>| throttle, suspend |
| has a policy, not yet acted on | | or kill it |
+-----------------------------------+ +----------+----------+
|
v
+---------------------+
| out of application |
| memory dialog |
+---------------------+
The policy is attached when a process is launched, through a posix_spawn attribute such as POSIX_SPAWN_PCONTROL_SUSPEND. On this Mac every ordinary app checked had the suspend policy: Safari, Terminal, Slack, Calendar, Ghostty. So did some helpers, including WebKit's networking and GPU processes and a wallpaper extension. Finder, the Dock and background daemons had none. The suspend branch in proc_dopcontrol() is short:
case P_PCSUSP:
PROC_SETACTION_STATE(p);
proc_unlock(p);
memorystatus_log("memorystatus: suspending %s [%d] due to swap exhaustion\n", ...);
task_suspend(proc_task(p));
break;
The kernel runs this at most once every five seconds, and each run acts on one process. Nothing in the kernel undoes it on its own. The only code that clears the paused state and releases the hold is proc_resetpcontrol(), which the kernel accepts only from root or from the system process that shows the out-of-memory dialog. Nothing resumes a paused app because memory has recovered. When the terminal was finally diagnosed, the system reported 74% of memory free, and it was still frozen.
A terminal with many long-lived tabs is close to the ideal victim. It is large, it spends most of its time in the background, and most of its memory, such as old scrollback, is rarely read, so it ends up compressed. "Largest compressed footprint" is exactly the rule the kernel uses to choose. Swap stood at 3.7 GB of 5 GB when this one was found.
Why ps calls it sleeping
task_suspend() places a Mach-level hold on every thread in the task. It is not a signal, so the BSD half of the process never moves to the stopped state. ps prints T only for that stopped state, so it keeps printing S, and the per-thread view, ps -M, showed S for every thread as well. The threads stop wherever they happened to be, which for an idle app means inside its normal waits. That is why the sample looked perfectly healthy: it was a photograph of an idle app that had been frozen mid-idle.
The one place the state shows is a pair of flags in the process's BSD info, defined in proc_info.h:
PROC_FLAG_PC_SUSP(0x400): the process's policy is to be suspended when memory runs out.PROC_FLAG_PA_SUSP(0x1000): the process is currently suspended because memory ran out.
Any user can read them for their own processes with proc_pidinfo(), no root needed. For the frozen terminal the answer was policy=suspend PAUSED, which settled the cause.
Finding the paused apps
A small Python script is enough to read the flags. It uses only the standard library:
#!/usr/bin/env python3
# usage: pflags.py <pid> [<pid> ...]
import ctypes, sys
libc = ctypes.CDLL("/usr/lib/libSystem.dylib")
buf = ctypes.create_string_buffer(136) # struct proc_bsdinfo
policies = {0x000: "none", 0x200: "throttle", 0x400: "suspend", 0x600: "kill"}
for pid in map(int, sys.argv[1:]):
if libc.proc_pidinfo(pid, 3, ctypes.c_uint64(0), buf, 136) <= 0: # PROC_PIDTBSDINFO
print(pid, "no access")
continue
flags = int.from_bytes(buf.raw[0:4], "little")
name = buf.raw[48:64].split(b"\0")[0].decode()
print(pid, name, "policy=" + policies[flags & 0x600],
"PAUSED" if flags & 0x1000 else "running")
$ python3 pflags.py $(pgrep -x ghostty) $(pgrep -x Safari)
60309 ghostty policy=suspend PAUSED
63778 Safari policy=suspend running
Without the script, CPU time is a good hint. An app that shows the beachball and whose ps -o time= -p <pid> does not move at all over several seconds is probably suspended rather than busy. A busy app burns CPU. A suspended one cannot.
Getting the apps back
The intended route is the Force Quit window, ⌥⌘⎋, where paused apps are listed and can be resumed. Resume calls proc_resetpcontrol(), which clears the flag and releases the hold. This time it did not bring the terminal back. What did was a plain signal:
kill -CONT $(pgrep -x ghostty)
The terminal had never been sent SIGSTOP, so it is not obvious that SIGCONT should do anything. It works because of how XNU handles the signal. The SIGCONT case in kern_sig.c calls task_resume_internal() unconditionally, and that releases one hold on the task whatever placed it, including the kernel's own task_suspend():
case SIGCONT:
...
(void) task_resume_internal(sig_task);
The effect was immediate. CPU time started moving within four seconds, resident memory went from 2.5 MB to 106 MB and then 160 MB as the app read its pages back from swap, and the next open -a Ghostty worked. The first seconds are sluggish while that happens. On a process that is not suspended the signal does nothing, because there is no hold to release.
There is one side effect. SIGCONT releases the hold but leaves the PA_SUSP flag set, because only proc_resetpcontrol() clears it. A flagged process is skipped in later passes, since the kernel only picks processes it has not acted on yet. So clicking Resume in Force Quit afterwards is still worthwhile. It clears the flag, and the extra resume it performs is harmless.
Eight apps at once
Nine days later the same Mac had beachballs in several apps at the same time. The flag check found eight paused processes: Slack, Calendar, Notes, Postman, the same Ghostty process again, WebKit's networking and GPU processes, and the wallpaper extension. None of them used any CPU over six seconds. Swap was at 4.5 GB of 6 GB.
Eight is not surprising. The kernel pauses one process per pass and can run a pass every five seconds, so a minute of shortage is enough to work down a row of apps. Resuming them one by one is tedious, so this resumes every process of yours that carries the flag:
python3 pflags.py $(pgrep -U "$USER") | awk '/PAUSED/ {print $1}' | xargs kill -CONT
Within eight seconds seven of the eight were using CPU again. The wallpaper extension simply had nothing to do and showed CPU use later. One zsh detail cost a retry: with the PIDs in a scalar variable, kill -CONT $pids fails with "illegal pid", because zsh does not split unquoted parameters into words the way bash does. An array or xargs avoids it.
The flags also explain why Resume had seemed useless the first time. After the first episode the terminal still carried PA_SUSP, and a flagged process is never picked again. To be paused a second time, its flag must have been cleared in between, and the only code that clears it on a running process is proc_resetpcontrol(), which is what Resume calls. Later the same day Slack, Ghostty and Calendar showed clear flags too, while Postman, Notes and the helpers still read PAUSED even though they were running normally. That is consistent with Resume working all along: when memory is still short, the kernel simply picks the biggest remaining app a few seconds later, and very often that is the app that was just resumed. Resuming only lasts once something has freed memory.
It also means a PAUSED line is not proof of a frozen app. After a SIGCONT the flag stays behind on a process that is running normally. The CPU-time check is what tells the two apart.
Keeping memory out of the red
None of this is a malfunction. It is the kernel choosing to freeze one large app rather than let the whole machine stall, and on a 16 GB Mac with long-lived apps it will happen again. A few habits make it rarer:
- Watch Memory Pressure in Activity Monitor. Yellow or red means the compressor is doing heavy work, and that is the time to close something, not when the dialog appears.
- Restart apps with large, long-lived footprints now and then. A terminal with many tabs open for weeks, or a browser, holds far more than it seems.
- Look for forgotten background work: old terminal sessions, dev servers, test runs. Each one holds memory while it is idle.
- In Ghostty,
scrollback-limitcaps each terminal's scrollback in bytes, 10 MB by default. Scrollback lives entirely in memory, so many tabs with long output add up. - Keep free disk space for swap. Here the disk had plenty, so the compressor limit was the likelier trigger, but a full disk brings the same dialog sooner.
And when an app beachballs while using no CPU at all, check the flag before force quitting it. A paused app is not hung, and kill -CONT gets it back with every tab intact.
Sources
- Apple, XNU source: bsd/kern/kern_proc.c (no_paging_space_action, proc_dopcontrol, proc_resetpcontrol)
- Apple, XNU source: bsd/kern/kern_memorystatus.c (no paging space throttle and out-of-memory notification)
- Apple, XNU source: bsd/kern/kern_sig.c (SIGCONT handling)
- Apple, XNU source: osfmk/kern/task.c (task_suspend and task holds)
- Apple, XNU source: bsd/kern/kern_exec.c (POSIX_SPAWN_PCONTROL attributes)
- Apple, XNU source: bsd/sys/proc_info.h (PROC_FLAG_PC_SUSP and PROC_FLAG_PA_SUSP)
- Apple, adv_cmds source: ps/print.c (process state letters)
- Apple, Check if your Mac needs more RAM in Activity Monitor
- Ghostty, configuration reference: scrollback-limit