claude-code - 💡(How to fix) Fix [Bug] Scheduled-task routines on macOS leak orphan claude subprocesses (kevent64 stall on stdin) [1 comments, 2 participants]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…
GitHub stats
anthropics/claude-code#57806Fetched 2026-05-11 03:24:54
View on GitHub
Comments
1
Participants
2
Timeline
6
Reactions
0
Author
Timeline (top)
labeled ×4commented ×1cross-referenced ×1

Fix Action

Fix / Workaround

sample 57240 1 — main thread parked in kevent64:

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread  (serial)
  883 start  (in dyld) + 6992
    883 (in claude binary) ... event loop ...
      883 kevent64  (in libsystem_kernel.dylib) + 8

sample 57239 1 — disclaimer parent blocked in __wait4 on the claude child (so it can never reap it on its own):

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread
  883 start  (in dyld) + 6992
    883 main  (in disclaimer) + 480
      883 __wait4  (in libsystem_kernel.dylib) + 8

Workaround (stopgap)

Code Example

pgrep -f 'claude.app/Contents/MacOS/claude' | wc -l

---

Claude.app                            (PID N,    desktop app — alive, healthy)
 └─ Helpers/disclaimer                (PID M,    blocked in __wait4 on its child)
     └─ ...claude.app/Contents/MacOS/claude
            --output-format stream-json --input-format stream-json
            --replay-user-messages --disallowedTools AskUserQuestion ...
                                      (PID M+1, blocked in kevent64 on fd 0/stdin)

---

UID    PID  PPID  STIME  CMD
501  57240 57239  2:02PM /Users/.../claude.app/Contents/MacOS/claude --output-format stream-json --verbose --input-format stream-json --model default --permission-prompt-tool stdio --allowedTools mcp__... --disallowedTools AskUserQuestion --setting-sources=user,project,local --permission-mode bypassPermissions --allow-dangerously-skip-permissions --include-partial-messages --plugin-dir ... --replay-user-messages --settings {}
501  57239 44268  2:02PM /Applications/Claude.app/Contents/Helpers/disclaimer .../claude.app/Contents/MacOS/claude ...
501  44268     1  Sat10AM /Applications/Claude.app/Contents/MacOS/Claude

---

COMMAND  PID  FD   TYPE  DEVICE              NAME
claude   57240  0u  unix  0x71302d6d7828758f  ->0x3e7ffe1aefbead56
claude   57240  1u  unix  0xb806ee172db2f101  ->0x32007e26c34cbde6
claude   57240  2u  unix  0x9cf0a3d666017603  ->0x288767784f690c89

---

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread  (serial)
  883 start  (in dyld) + 6992
    883 (in claude binary) ... event loop ...
      883 kevent64  (in libsystem_kernel.dylib) + 8

---

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread
  883 start  (in dyld) + 6992
    883 main  (in disclaimer) + 480
      883 __wait4  (in libsystem_kernel.dylib) + 8

---

#!/usr/bin/env bash
# Reap orphan local-agent-mode `claude` subprocesses spawned by Claude.app's
# scheduled-task routines and stuck in kevent64 on stdio sockets to a
# wait4-blocked `disclaimer` parent.
set -euo pipefail

THRESHOLD=${CLAUDE_REAPER_THRESHOLD:-900}    # 15 min — never touch fresh spawns
GRACE=${CLAUDE_REAPER_GRACE:-10}             # SIGTERM, wait, then SIGKILL
LOG="$HOME/Library/Logs/claude-reaper.log"
ts() { /bin/date -u +%Y-%m-%dT%H:%M:%SZ; }

DESKTOP_PID=$(/usr/bin/pgrep -x -n Claude 2>/dev/null || true)

candidates=$(/bin/ps -axo pid,ppid,etime,command \
  | /usr/bin/awk -v t="$THRESHOLD" '
      function s(x,   n,a){ n=split(x,a,/[-:]/);
        if(n==4) return a[1]*86400 + a[2]*3600 + a[3]*60 + a[4];
        if(n==3) return a[1]*3600 + a[2]*60 + a[3];
        if(n==2) return a[1]*60 + a[2];
        return a[1]+0 }
      /--replay-user-messages/ \
        && /--disallowedTools AskUserQuestion/ \
        && /claude\.app\/Contents\/MacOS\/claude/ \
        && s($3) > t { print $1, $2, s($3) }')

[[ -z "$candidates" ]] && exit 0

claude_pids=(); disclaimer_pids=(); oldest_pid=""; oldest_age=0
while IFS=' ' read -r pid ppid age; do
  [[ -z "$pid" || -z "$ppid" ]] && continue
  ppid_cmd=$(/bin/ps -o command= -p "$ppid" 2>/dev/null || true)
  ppid_ppid=$(/bin/ps -o ppid= -p "$ppid" 2>/dev/null | /usr/bin/tr -d ' ' || true)
  if [[ "$ppid_cmd" == *"/Applications/Claude.app/Contents/Helpers/disclaimer"* ]]; then
    if [[ -n "$DESKTOP_PID" && "$ppid_ppid" == "$DESKTOP_PID" ]] \
       || [[ -z "$DESKTOP_PID" && "$ppid_ppid" == "1" ]]; then
      disclaimer_pids+=("$ppid")
    fi
  fi
  claude_pids+=("$pid")
  if (( age > oldest_age )); then oldest_age=$age; oldest_pid=$pid; fi
done <<< "$candidates"

[[ ${#claude_pids[@]} -eq 0 ]] && exit 0

for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
  /bin/kill -TERM "$pid" 2>/dev/null || true
done
/bin/sleep "$GRACE"

kill_count=0
for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
  if /bin/kill -0 "$pid" 2>/dev/null; then
    /bin/kill -KILL "$pid" 2>/dev/null || true
    kill_count=$((kill_count + 1))
  fi
done

echo "[$(ts)] term=${#claude_pids[@]} kill=${kill_count} disclaimer=${#disclaimer_pids[@]} oldest_pid=${oldest_pid} oldest_age_s=${oldest_age}" >> "$LOG"

---

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>          <string>com.example.claude-reaper</string>
  <key>ProgramArguments</key>
  <array><string>/Users/YOU/.local/bin/claude-orphan-reaper</string></array>
  <key>StartInterval</key>  <integer>600</integer>
  <key>RunAtLoad</key>      <true/>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key>  <string>/Users/YOU</string>
    <key>PATH</key>  <string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StandardOutPath</key> <string>/Users/YOU/Library/Logs/claude-reaper.out.log</string>
  <key>StandardErrorPath</key><string>/Users/YOU/Library/Logs/claude-reaper.err.log</string>
</dict>
</plist>
RAW_BUFFERClick to expand / collapse

What's wrong

When the macOS Claude.app desktop app spawns a one-shot claude CLI subprocess for a scheduled-task routine (under ~/.claude/scheduled-tasks/<routine>/), the subprocess never exits after the skill completes. The Stop hook: Query completed log line fires, but the claude process stays alive indefinitely, blocked in kevent64 waiting on its stdin (a unix-domain-socket pipe to its disclaimer wrapper parent, which nothing is ever going to write to again).

One orphan accumulates per cron fire. With a routine firing every 15 minutes, ~96/day. Over ~26 hours of accumulation I had 234 orphans consuming ~20 GB RAM, ~7.9 GB swap, with system load average 163 — the desktop UI became unusable until I manually killed them.

Expected behavior

When the skill stop hook fires for a non-interactive routine session (started with --replay-user-messages --disallowedTools AskUserQuestion), the claude subprocess should exit cleanly. Either:

  • The claude binary should detect "no more queued user messages + stop hook fired + non-interactive flags" and exit on its own, or
  • The disclaimer wrapper / desktop harness should close the child's stdin (EOF) or SIGTERM the child after the skill completes.

Steps to reproduce

  1. Set up any scheduled-task routine that fires periodically — e.g. a ~/.claude/scheduled-tasks/<name>/ directory configured to run every 15 min via the desktop app's scheduling.
  2. Leave Claude.app running so it can fire the routine.
  3. After each fire, the skill completes successfully (log line Stop hook: Query completed appears in ~/Library/Logs/Claude/...).
  4. Observe orphan accumulation:
    pgrep -f 'claude.app/Contents/MacOS/claude' | wc -l
    Count grows by exactly one per cron fire. Number never decreases on its own.
  5. Inspect any orphan: ps, lsof, and sample show the chain described below.

Spawn chain (deadlock)

Claude.app                            (PID N,    desktop app — alive, healthy)
 └─ Helpers/disclaimer                (PID M,    blocked in __wait4 on its child)
     └─ ...claude.app/Contents/MacOS/claude
            --output-format stream-json --input-format stream-json
            --replay-user-messages --disallowedTools AskUserQuestion ...
                                      (PID M+1, blocked in kevent64 on fd 0/stdin)

disclaimer is a wait4-only parent with no event loop, so it won't write to claude's stdin. claude is in stream-json input mode waiting on stdin for the next user message, which never arrives. The skill's stop hook fires, but nothing translates that into "send EOF on stdin" or "SIGTERM the child."

SIGTERM to the orphan claude PID interrupts the kevent64 wait and the process exits cleanly (and the disclaimer immediately exits when its child reaps), so this is not an unkillable-D-state hang — just a missing signal source.

Diagnostic evidence (captured live)

ps -fp <orphan>:

UID    PID  PPID  STIME  CMD
501  57240 57239  2:02PM /Users/.../claude.app/Contents/MacOS/claude --output-format stream-json --verbose --input-format stream-json --model default --permission-prompt-tool stdio --allowedTools mcp__... --disallowedTools AskUserQuestion --setting-sources=user,project,local --permission-mode bypassPermissions --allow-dangerously-skip-permissions --include-partial-messages --plugin-dir ... --replay-user-messages --settings {}
501  57239 44268  2:02PM /Applications/Claude.app/Contents/Helpers/disclaimer .../claude.app/Contents/MacOS/claude ...
501  44268     1  Sat10AM /Applications/Claude.app/Contents/MacOS/Claude

lsof -p 57240 -a -d 0,1,2 — stdio bound to anonymous unix-domain sockets, peer is the live disclaimer:

COMMAND  PID  FD   TYPE  DEVICE              NAME
claude   57240  0u  unix  0x71302d6d7828758f  ->0x3e7ffe1aefbead56
claude   57240  1u  unix  0xb806ee172db2f101  ->0x32007e26c34cbde6
claude   57240  2u  unix  0x9cf0a3d666017603  ->0x288767784f690c89

sample 57240 1 — main thread parked in kevent64:

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread  (serial)
  883 start  (in dyld) + 6992
    883 (in claude binary) ... event loop ...
      883 kevent64  (in libsystem_kernel.dylib) + 8

sample 57239 1 — disclaimer parent blocked in __wait4 on the claude child (so it can never reap it on its own):

883 Thread_xxx  DispatchQueue_1: com.apple.main-thread
  883 start  (in dyld) + 6992
    883 main  (in disclaimer) + 480
      883 __wait4  (in libsystem_kernel.dylib) + 8

Environment

  • macOS: 26.4.1 (build 25E253), Darwin kernel 25.4.0, Apple Silicon (arm64)
  • Claude.app desktop: 1.6608.2 (CFBundleVersion 1.6608.2)
  • Bundled claude CLI (the binary that hangs): 2.1.128, bundled inside the app under ~/Library/Application Support/Claude/claude-code/2.1.128/claude.app/Contents/MacOS/claude
  • System claude --version: 2.1.138 — not used by the desktop spawn path; included for completeness
  • Reproducible at every cron fire over multiple days.

Discriminator: which processes are affected vs not

Process kind--replay-user-messages--disallowedTools AskUserQuestionAffected?
Routine sessions spawned by Claude.app's scheduleryes — these orphan
Interactive Claude.app sessionsno
claude --channels keep-alivesno

So the bug is specific to the non-interactive scheduled-task spawn path in the desktop app, not the general CLI.

Workaround (stopgap)

A launchd agent that runs every 10 min, finds claude processes matching all of:

  • argv contains --replay-user-messages
  • argv contains --disallowedTools AskUserQuestion
  • argv[0] matches claude.app/Contents/MacOS/claude
  • process age > 15 min

…and SIGTERMs them (and their disclaimer wrappers, after path-validating). The discriminator is exclusive, so interactive sessions and claude --channels keep-alives are never touched. SIGTERM is enough — kevent64 wait is signal-interruptible.

<details> <summary>Reaper script — <code>~/.local/bin/claude-orphan-reaper</code></summary>
#!/usr/bin/env bash
# Reap orphan local-agent-mode `claude` subprocesses spawned by Claude.app's
# scheduled-task routines and stuck in kevent64 on stdio sockets to a
# wait4-blocked `disclaimer` parent.
set -euo pipefail

THRESHOLD=${CLAUDE_REAPER_THRESHOLD:-900}    # 15 min — never touch fresh spawns
GRACE=${CLAUDE_REAPER_GRACE:-10}             # SIGTERM, wait, then SIGKILL
LOG="$HOME/Library/Logs/claude-reaper.log"
ts() { /bin/date -u +%Y-%m-%dT%H:%M:%SZ; }

DESKTOP_PID=$(/usr/bin/pgrep -x -n Claude 2>/dev/null || true)

candidates=$(/bin/ps -axo pid,ppid,etime,command \
  | /usr/bin/awk -v t="$THRESHOLD" '
      function s(x,   n,a){ n=split(x,a,/[-:]/);
        if(n==4) return a[1]*86400 + a[2]*3600 + a[3]*60 + a[4];
        if(n==3) return a[1]*3600 + a[2]*60 + a[3];
        if(n==2) return a[1]*60 + a[2];
        return a[1]+0 }
      /--replay-user-messages/ \
        && /--disallowedTools AskUserQuestion/ \
        && /claude\.app\/Contents\/MacOS\/claude/ \
        && s($3) > t { print $1, $2, s($3) }')

[[ -z "$candidates" ]] && exit 0

claude_pids=(); disclaimer_pids=(); oldest_pid=""; oldest_age=0
while IFS=' ' read -r pid ppid age; do
  [[ -z "$pid" || -z "$ppid" ]] && continue
  ppid_cmd=$(/bin/ps -o command= -p "$ppid" 2>/dev/null || true)
  ppid_ppid=$(/bin/ps -o ppid= -p "$ppid" 2>/dev/null | /usr/bin/tr -d ' ' || true)
  if [[ "$ppid_cmd" == *"/Applications/Claude.app/Contents/Helpers/disclaimer"* ]]; then
    if [[ -n "$DESKTOP_PID" && "$ppid_ppid" == "$DESKTOP_PID" ]] \
       || [[ -z "$DESKTOP_PID" && "$ppid_ppid" == "1" ]]; then
      disclaimer_pids+=("$ppid")
    fi
  fi
  claude_pids+=("$pid")
  if (( age > oldest_age )); then oldest_age=$age; oldest_pid=$pid; fi
done <<< "$candidates"

[[ ${#claude_pids[@]} -eq 0 ]] && exit 0

for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
  /bin/kill -TERM "$pid" 2>/dev/null || true
done
/bin/sleep "$GRACE"

kill_count=0
for pid in "${claude_pids[@]}" "${disclaimer_pids[@]}"; do
  if /bin/kill -0 "$pid" 2>/dev/null; then
    /bin/kill -KILL "$pid" 2>/dev/null || true
    kill_count=$((kill_count + 1))
  fi
done

echo "[$(ts)] term=${#claude_pids[@]} kill=${kill_count} disclaimer=${#disclaimer_pids[@]} oldest_pid=${oldest_pid} oldest_age_s=${oldest_age}" >> "$LOG"
</details> <details> <summary>launchd plist — <code>~/Library/LaunchAgents/com.example.claude-reaper.plist</code></summary>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
  <key>Label</key>          <string>com.example.claude-reaper</string>
  <key>ProgramArguments</key>
  <array><string>/Users/YOU/.local/bin/claude-orphan-reaper</string></array>
  <key>StartInterval</key>  <integer>600</integer>
  <key>RunAtLoad</key>      <true/>
  <key>EnvironmentVariables</key>
  <dict>
    <key>HOME</key>  <string>/Users/YOU</string>
    <key>PATH</key>  <string>/Users/YOU/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
  </dict>
  <key>StandardOutPath</key> <string>/Users/YOU/Library/Logs/claude-reaper.out.log</string>
  <key>StandardErrorPath</key><string>/Users/YOU/Library/Logs/claude-reaper.err.log</string>
</dict>
</plist>

Install: chmod +x ~/.local/bin/claude-orphan-reaper && launchctl load -w ~/Library/LaunchAgents/com.example.claude-reaper.plist

</details>

This is reactive cleanup, not a fix. The real fix should be in the desktop app's scheduled-task spawn path — either close stdin on the child after the stop hook fires, or have the bundled claude CLI exit on its own when it's in --replay-user-messages --disallowedTools AskUserQuestion mode and the queued message stream is exhausted.

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

FAQ

Expected behavior

When the skill stop hook fires for a non-interactive routine session (started with --replay-user-messages --disallowedTools AskUserQuestion), the claude subprocess should exit cleanly. Either:

  • The claude binary should detect "no more queued user messages + stop hook fired + non-interactive flags" and exit on its own, or
  • The disclaimer wrapper / desktop harness should close the child's stdin (EOF) or SIGTERM the child after the skill completes.

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING

claude-code - 💡(How to fix) Fix [Bug] Scheduled-task routines on macOS leak orphan claude subprocesses (kevent64 stall on stdin) [1 comments, 2 participants]