Debugging Process Hangs on Mac: A Systematic Approach

How I debugged a complete deadlock in Claude Code using macOS tools like sample, lsof, and ps to trace an event loop hang

I ran into a deadlock recently while testing Claude Code. Running /mcp reconnect playwright in a project without Playwright configured would show an error, then immediately freeze the entire session. Couldn’t type, Escape did nothing - had to kill -9 it.

This seemed like a good opportunity to practice systematic debugging and document the process. Here’s how I tracked down the root cause.

The Problem

  • Command: /mcp reconnect playwright
  • Response: ✘ Failed to reconnect to playwright / Error: MCP server "playwright" not found
  • Result: Complete freeze, no recovery possible

Reproducing the Issue

Before diving into debugging, I needed to confirm this wasn’t a one-off fluke. I killed the frozen process and tried again:

  1. Open a fresh Claude session
  2. Navigate to a project without Playwright configured
  3. Run /mcp reconnect playwright
  4. Wait for error message
  5. Try to type anything → frozen

Result: 100% reproducible. Same freeze every time.

This is critical. If you can’t reproduce a bug, you’re mostly guessing. Reproducibility means:

  • You can test your hypothesis
  • You can verify your fix
  • You can provide clear steps in a bug report

One-off hangs? Document what you saw, but don’t spend hours debugging something that might never happen again.

Finding the Hanging Process

I had multiple Claude sessions running in tmux panes. First problem: which process was actually stuck?

1
ps aux | grep claude

This gave me several PIDs. But which one was frozen? I needed to match the PID to the working directory:

1
lsof -p 12061 -p 36125 -p 44674 -p 48811 | grep cwd

Output showed PID 48811 was in the aisystemsprimer directory - that’s where I reproduced the bug. Found it.

First Attempt: USR2 Signal

My initial instinct was to get a diagnostic report using Node.js’s built-in signal handler:

1
kill -USR2 48811

This killed the process outright instead of generating a report. Makes sense in retrospect - if the event loop is deadlocked, signal handlers can’t execute. The process can’t respond to anything.

Getting a Stack Trace

On macOS, sample is the right tool for this. It profiles a process without disturbing it:

1
sample 48811 5 -file ~/stack_trace.txt

The -file flag saves output to a file. The 5 means sample for 5 seconds.

The stack trace showed the main thread stuck here:

+   3913 uv__io_poll  (in libuv.1.0.0.dylib) + 724
+     3913 kevent  (in libsystem_kernel.dylib) + 8

The event loop was blocked in kevent(), waiting for I/O that would never arrive. Classic deadlock pattern.

What Was It Waiting For?

Time to check file descriptors:

1
lsof -p 48811 | grep PIPE

Output:

node    48811 sandman   13      PIPE 0xac44d11643cbe7d8    16384    ->0x301027970e85d0dd
node    48811 sandman   14      PIPE 0x301027970e85d0dd    16384    ->0xac44d11643cbe7d8

Two open pipes. These are typically used for IPC (inter-process communication) with child processes. But was there a child process?

1
ps -o pid,ppid,command | awk '$2 == 48811'

Nothing. No child process.

The pipes were opened for communication with a child that never spawned. The main thread was waiting on I/O that would never arrive because the other end didn’t exist.

Checking Debug Logs

Claude Code keeps debug logs in ~/.claude/debug/. I needed to find the log for this session:

1
grep -l "tmp.48811" ~/.claude/debug/*.txt

Found: 6c752d67-d15d-4ae1-b12b-723ab852f4eb.txt

1
tail -100 ~/.claude/debug/6c752d67-d15d-4ae1-b12b-723ab852f4eb.txt

Last entries:

[DEBUG] Getting matching hook commands for SubagentStop with query: undefined
[DEBUG] Found 0 hook matchers in settings
[DEBUG] AutoUpdaterWrapper: Installation type: npm-global

Then I searched for MCP-related activity:

1
grep -i "mcp\|reconnect\|playwright" ~/.claude/debug/6c752d67-d15d-4ae1-b12b-723ab852f4eb.txt

Zero results. The /mcp reconnect command was never logged, which means the hang happened before the event loop could process the next iteration.

Comparing With a Working Session

I checked a working session’s logs where Playwright MCP was properly configured:

1
tail -100 ~/.claude/debug/7928bf05-1443-4731-ba34-50369be48ec9.txt | grep -i mcp
[DEBUG] MCP server "playwright": Successfully connected to stdio server in 637ms
[DEBUG] MCP server "playwright": Connection established with capabilities: {...}

Clear difference: working sessions complete the entire connection flow.

Root Cause

Connecting the evidence:

  1. Error message displays correctly (“MCP server not found”)
  2. Despite the error, code attempts to connect anyway
  3. Opens pipes for IPC with the would-be child process
  4. Child process spawn fails (silently)
  5. Main thread blocks on kevent() waiting for I/O from non-existent child
  6. No timeout implemented
  7. Deadlock

The /mcp reconnect command doesn’t validate server existence before attempting connection. It allocates resources (pipes) then blocks indefinitely waiting for a process that never started.

Collecting Evidence

I organized everything for the bug report:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
mkdir ~/claude_hang_bug_report

# Hanging session log
cp ~/.claude/debug/6c752d67-d15d-4ae1-b12b-723ab852f4eb.txt \
   ~/claude_hang_bug_report/hanging_session_log.txt

# Stack trace
cp ~/stack_trace.txt ~/claude_hang_bug_report/

# Working session for comparison
cp ~/.claude/debug/7928bf05-1443-4731-ba34-50369be48ec9.txt \
   ~/claude_hang_bug_report/working_session_log.txt

# Full lsof output
lsof -p 48811 > ~/claude_hang_bug_report/lsof_full.txt

# Process state
ps -p 48811 -o pid,ppid,state,etime,%cpu,%mem,command > \
   ~/claude_hang_bug_report/process_state.txt

# System info
claude --version > ~/claude_hang_bug_report/system_info.txt
node --version >> ~/claude_hang_bug_report/system_info.txt
sw_vers >> ~/claude_hang_bug_report/system_info.txt

The Tools

ps - Process listing. Shows PIDs, resource usage, process states. Essential for identifying which process is the problem.

lsof - List open files. Critical for seeing file descriptors, sockets, pipes. In this case, revealed the open pipes with no corresponding child process.

sample - macOS sampling profiler. Non-invasive stack trace collection. Showed exactly where the process was stuck (kevent() in the event loop).

grep - Pattern search through logs. Here, the absence of “mcp” in logs was as informative as any error message.

Process tree analysis - ps -o pid,ppid,command to check parent-child relationships. The missing child process was key evidence.

Debug Flow

Here’s the systematic approach:

graph TD A[Process Hung] --> B{Can Reproduce?} B -->|No| C[Document What Happened] C --> D[Monitor for Recurrence] B -->|Yes| E[Identify PID] E --> F[Check Working Directory] F --> G{Correct Process?} G -->|No| E G -->|Yes| H[Capture Stack Trace] H --> I[Analyze File Descriptors] I --> J[Check Process Tree] J --> K[Review Debug Logs] K --> L[Compare with Working Case] L --> M[Form Hypothesis] M --> N[Collect Evidence] N --> O[Write Up Notes] O --> P[File Bug Report] style B fill:#fff3e0 style O fill:#e8f5e9 style P fill:#e8f5e9

Step by step:

0. Try to reproduce - Before deep investigation, confirm you can trigger the hang consistently

  • If not reproducible: Document what happened, save any logs, move on
  • If reproducible: Proceed with systematic debugging

1. Isolate the affected process - Use lsof to verify working directory

  • Multiple processes running? Match PID to the right directory
  • lsof -p PID1 -p PID2 | grep cwd

2. Capture state non-invasively

  • sample PID duration for stack trace
  • lsof -p PID for file descriptors
  • ps -o pid,ppid,command for process tree
  • Don’t use kill -USR2 on a hung process - it may kill it instead

3. Analyze what you captured

  • Stack trace → where it’s stuck (kevent)
  • File descriptors → what it’s waiting for (pipes)
  • Process tree → missing child process
  • Logs → when it stopped (before command execution)

4. Compare with working case - Identify where behavior diverges

  • Run the command in a working scenario
  • Note what happens differently

5. Form hypothesis - Connect evidence to root cause

  • Event loop blocked waiting for child process that never spawned

6. Write up notes - Document your findings before losing context

  • Steps to reproduce
  • Evidence collected (stack traces, file descriptors, logs)
  • Hypothesis about root cause
  • Screenshots or terminal output if helpful

7. File bug report - Share your investigation with maintainers

  • Include reproduction steps
  • Attach diagnostic data
  • Link to your notes or GitHub issue

Key Lessons

Reproducibility is everything - Don’t spend hours debugging something you can’t reproduce. If it happens once and never again, document it and move on. If you can reproduce it reliably, you can debug it systematically.

Write up notes before you forget - Context fades fast. After collecting evidence, write down your findings immediately: reproduction steps, stack traces, hypothesis. Future you (or the maintainer reading your bug report) will thank you.

Verify assumptions early - I initially guessed which process was hanging based on timing. Using lsof to check the working directory confirmed it immediately.

Use non-invasive tools first - kill -USR2 killed the process. sample captured what I needed without disturbing anything.

Absence is information - No MCP log entries told me the command never executed, which narrowed down where the bug occurred.

Compare working vs broken - Seeing what should happen makes it much clearer what went wrong.

Stack traces reveal mechanisms - Seeing kevent() immediately suggested an event loop issue, which directed me toward IPC investigation.

File descriptors don’t lie - Open pipes with no child process was definitive proof the spawn failed.

Collect before killing - Once you kill the process, all evidence disappears. Get stack traces and state snapshots while it’s frozen.

The GitHub Issue

I filed a detailed bug report including:

  • Exact reproduction steps
  • Stack trace showing kevent() deadlock
  • File descriptor analysis (open pipes, no child process)
  • Debug log evidence (command never logged)
  • Working session comparison for contrast
  • Root cause hypothesis with supporting evidence
  • All collected diagnostic data as attachments

Impact: High severity - complete session loss, no recovery possible, 100% reproducible

Root cause: Missing validation check + no timeout in MCP reconnection logic

Quick Reference

ToolPurposeExample
psList processesps aux | grep process_name
lsofView open files/descriptorslsof -p PID | grep PIPE
sampleGet stack tracesample PID 5 -file trace.txt
grepSearch logsgrep -i "error" logfile.txt
killSend signalskill -USR2 PID

What started as a frustrating hang turned into an interesting debugging exercise. The key was systematic evidence collection and understanding what each tool reveals about process state. When you’re stuck with a hung process, these macOS tools give you everything you need to figure out why.

Filed: https://github.com/anthropics/claude-code/issues/11385

comments powered by Disqus

© 2025 Santosh Manoharan