How to get stacks out of a Python process that is hung right now, and how to prepare processes so the next hang dumps itself. Ordered from "no preparation needed" to "built into your app".
Contents
- Which tool when
- hang_watchdog.py (re-runnable targets)
- py-spy (attach without changes)
- gdb with the CPython extension
- Python 3.14: asyncio ps/pstree, pdb -p, sys.remote_exec
- faulthandler
- In-process introspection: sys._current_frames, threading.enumerate
- asyncio task introspection
- asyncio debug mode and slow callbacks
- Library mode: install(), dump_all(), heartbeat()
- Containers, Kubernetes and permissions
- OS-level evidence
Which tool when
| Situation | First choice | Fallback |
|---|---|---|
| You can re-run the command | hang_watchdog.py --dump-after N --kill-after M | timeout -s USR1 + faulthandler |
| Live process, no preparation | py-spy dump --pid PID | gdb -p PID + py-bt |
| Live asyncio process on 3.14 | python -m asyncio pstree PID | python -m pdb -p PID, then inspect tasks |
| GIL held / C extension stuck | py-spy dump --native --pid PID | faulthandler signal, gdb thread apply all bt |
| Production service | install() or faulthandler.register(SIGUSR1) at startup | heartbeat watchdog thread |
| Test suite / CI job | pytest faulthandler_timeout, pytest-timeout | faulthandler.dump_traceback_later(..., exit=True) |
| Slow, not hung | py-spy top / py-spy record | asyncio debug mode, cProfile |
hang_watchdog.py (re-runnable targets)
scripts/hang_watchdog.py executes the target in a child process in its own process group, with a
bootstrap that registers faulthandler on SIGUSR1 and starts a daemon dumper thread.
python scripts/hang_watchdog.py --dump-after 30 --kill-after 120 eval_harness.py --shard 3
python scripts/hang_watchdog.py --idle 60 --kill-after 3600 -m myproj.embed_job --batch 512
python scripts/hang_watchdog.py --dump-after 1 --kill-after 3 --json deadlock.py > report.jsonWhat a dump contains:
- summary: each thread's innermost user frame (plus innermost frame overall) and a guess at what it
blocks on (
[lock acquire],[join],[queue get],[sleep],[idle in poll]); each event loop asresponsiveorBLOCKED; each task's deepest user frame and what it awaits (lock/event/queue with its variable name when found, another task, a gather and its children). - threads: full stacks with thread names, daemon/main flags; the bootstrap frames are hidden.
- asyncio tasks: full await chain per task, outermost first (stdlib
Task.get_stack()shows only one frame for a suspended coroutine; the watchdog followscr_awaitall the way down). - fallback: if the in-process dumper does not answer within
--dump-timeout, the watchdog sends SIGUSR1 and includes faulthandler's C-level dump (works while the GIL is held).
Process hygiene: --kill-after always ends the whole process group (SIGTERM, then SIGKILL after
--grace), leftover grandchildren are killed even after a normal exit, the watchdog's own Ctrl+C kills the
group, and on Linux the child gets PR_SET_PDEATHSIG. A grandchild that calls setsid() leaves the group
and is not tracked.
Exit codes: 0 target exited 0, 1 target exited non-zero, 2 usage error, 3 hung and killed (or dumped with
--fail-on-dump), 130 interrupted. In --json mode the summary (status, return code, every dump's text) is
the only thing on stdout.
py-spy (attach without changes)
py-spy is a sampling profiler written in Rust that reads the target's memory; no code changes, no restart, works when the GIL is held.
py-spy dump --pid 12345 # every thread's Python stack, innermost first, with thread names
py-spy dump --pid 12345 --native # include C/C++/Rust frames (where a C extension is stuck)
py-spy dump --pid 12345 --locals # local variables of each frame (which item? which lock?)
py-spy top --pid 12345 # live view: is it spinning, and where
py-spy record -o profile.svg --pid 12345 --duration 30 # flame graph of a slow (not hung) processNotes: needs ptrace permission (root, CAP_SYS_PTRACE, or kernel.yama.ptrace_scope=0); in Docker add
--cap-add SYS_PTRACE. It shows threads, not asyncio tasks: a deadlocked asyncio app appears as the loop
idling in select/epoll. New CPython releases need a py-spy release that knows their memory layout;
check its changelog for your version before relying on it.
gdb with the CPython extension
When nothing else is installed but gdb is, CPython's gdb helpers (python-gdb.py, shipped with debug
symbols packages such as python3-dbg on Debian/Ubuntu, python3-debuginfo on Fedora) add py-bt,
py-list, py-locals:
gdb -p 12345 -batch -ex "thread apply all py-bt" # Python stacks for every thread
gdb -p 12345 -batch -ex "thread apply all bt" # C stacks: where a C extension is blocked
gdb -p 12345 -batch -ex "info threads"Without the helpers you still get C stacks: frames in PyThread_acquire_lock_timed / pthread_cond_wait
mean a Python lock wait; take_gil means a thread waiting for the GIL; sem_wait/futex are generic waits.
Attaching pauses the process briefly; detaching resumes it.
Python 3.14: asyncio ps/pstree, pdb -p, sys.remote_exec
PEP 768 (3.14) added a safe external debugger interface. Built on it:
python3.14 -m asyncio ps 12345 # table of every asyncio task: id, name, coroutine stack, awaiter
python3.14 -m asyncio pstree 12345 # tree of who awaits whom; reports cycles in the await graph
python3.14 -m pdb -p 12345 # attach the debugger to a running processsys.remote_exec(pid, "/path/script.py") (3.14) makes the target run a Python file at its next safe point.
With it you can call faulthandler.dump_traceback() or this skill's dump_all() inside a process that
was not prepared for it. All of these need the same Python version on both sides, ptrace permission, and
can be disabled in the target (PYTHON_DISABLE_REMOTE_DEBUG=1, -X disable-remote-debug, or a build flag).
In-process, 3.14 also offers asyncio.capture_call_graph(), asyncio.format_call_graph() and
asyncio.print_call_graph() for the current or a given task.
faulthandler
Stdlib, C-level, async-signal-safe dumping of all thread stacks. It works when the GIL is held and when the interpreter is wedged, which is why it is the last-resort fallback everywhere.
import faulthandler
import signal
import sys
faulthandler.enable(file=sys.stderr, all_threads=True) # SIGSEGV, SIGFPE, SIGABRT, SIGBUS, SIGILL
faulthandler.register(signal.SIGUSR1, file=sys.stderr, all_threads=True, chain=False) # POSIX only
faulthandler.dump_traceback_later(timeout=1800, repeat=False, exit=False) # one timed dump
faulthandler.cancel_dump_traceback_later()
faulthandler.dump_traceback(file=sys.stderr, all_threads=True) # dump right now- From outside:
kill -USR1 PIDafterregister. Enable without code changes withpython -X faulthandlerorPYTHONFAULTHANDLER=1(fatal errors only, not the signal). - CI:
faulthandler.dump_traceback_later(timeout, exit=True)dumps and exits the process (hard_exit) before the CI runner kills it silently. pytest has this built in:faulthandler_timeout = 300in the ini file dumps stacks of tests that run longer than 300 s. - Keep the file object alive (faulthandler stores the file descriptor, not the object); writing to a closed fd prints nothing.
- Output is "most recent call first" and lists threads by id; on recent versions (seen on 3.14) the OS thread name follows in brackets, truncated to 15 characters. No asyncio tasks, no locals.
- 3.14 also adds
faulthandler.dump_c_stack()for C-level frames on supported platforms.
In-process introspection: sys._current_frames, threading.enumerate
import sys
import threading
import traceback
def dump_threads() -> str:
names = {t.ident: (t.name, t.daemon) for t in threading.enumerate()}
out = []
for ident, frame in sys._current_frames().items():
name, daemon = names.get(ident, (f"<unknown {ident:#x}>", None))
out.append(f"--- thread {name!r} daemon={daemon}\n" + "".join(traceback.format_stack(frame)))
return "\n".join(out)
worker = threading.Thread(target=threading.Event().wait, args=(0.5,), name="waiter")
worker.start()
text = dump_threads()
worker.join()
print("thread 'waiter'" in text, "thread 'MainThread'" in text) # True Truesys._current_frames() is CPython-specific but stable for years. threading.enumerate() lists only
threads started through threading (and the main thread); faulthandler and py-spy also see threads
created by C libraries.
asyncio task introspection
import asyncio
def await_chain(coro):
"""Frames of a coroutine and everything it awaits, outermost first."""
frames = []
while coro is not None:
frame = getattr(coro, "cr_frame", None) or getattr(coro, "gi_frame", None)
if frame is not None:
frames.append(frame)
coro = getattr(coro, "cr_await", None) or getattr(coro, "gi_yieldfrom", None)
if coro is not None and not hasattr(coro, "cr_frame") and not hasattr(coro, "gi_frame"):
break
return frames
async def tool_call():
await asyncio.Event().wait() # never set
async def agent_step():
await tool_call()
async def main():
t = asyncio.create_task(agent_step(), name="agent-step")
await asyncio.sleep(0.01)
for task in asyncio.all_tasks():
if task is asyncio.current_task():
continue
names = [f.f_code.co_name for f in await_chain(task.get_coro())]
print(task.get_name(), "->", " -> ".join(names))
t.cancel()
asyncio.run(main()) # agent-step -> agent_step -> tool_call -> waitasyncio.all_tasks(loop)returns pending tasks;asyncio.current_task()the running one.task.print_stack()/task.get_stack()show only the task's own coroutine frame while it is suspended, not the frames it awaits: walkcr_awaitas above (or use the watchdog, or 3.14's call-graph API).- Name tasks at creation (
name=oncreate_task, 3.8+);Task-123is useless in a dump. - Calling
all_tasks()from another thread is not formally thread-safe; schedule the inspection on the loop withloop.call_soon_threadsafeand fall back to reading from outside only when the loop does not answer (that is what the watchdog does).
asyncio debug mode and slow callbacks
import asyncio
import logging
import time
logging.basicConfig(level=logging.WARNING)
async def blocking_handler():
time.sleep(0.2) # the bug debug mode reports
async def main():
asyncio.get_running_loop().slow_callback_duration = 0.05 # default 0.1 s
await blocking_handler()
asyncio.run(main(), debug=True)
# WARNING:asyncio:Executing <Task finished name='Task-1' coro=<main() ...> ...> took 0.200 secondsEnable with asyncio.run(main(), debug=True), PYTHONASYNCIODEBUG=1, or python -X dev. Debug mode also
reports never-awaited coroutines with the creation traceback, non-threadsafe calls from the wrong thread
(call_soon from another thread raises), and unclosed transports. It adds overhead: use it in tests and
staging, not permanently in production.
Library mode: install(), dump_all(), heartbeat()
hang_watchdog.py is importable. Copy it next to your app (stdlib only) or add the scripts folder to
sys.path:
import asyncio
import os
import signal
import sys
sys.path.insert(0, "skills/programming/python/python-async-deadlock-debugger/scripts")
from hang_watchdog import dump_all, heartbeat, install # noqa: E402
install() # kill -USR1 <pid> -> threads + asyncio tasks on stderr
# install(dump_after=300) # also dump once, automatically, after 300 s
# install(c_level_signal=signal.SIGUSR2) # plus a faulthandler dump that works with the GIL held
async def main():
heartbeat() # progress marker for `hang_watchdog.py --idle`
os.kill(os.getpid(), signal.SIGUSR1)
await asyncio.sleep(0.6) # the dumper thread prints while we wait
asyncio.run(main())
text = dump_all(file=open(os.devnull, "w"), reason="on demand")
print("=== end hang_watchdog dump ===" in text) # Trueinstall() registers a Python-level signal handler that only writes to a pipe; a daemon thread does the
dump, so it works while the loop or the main thread is stuck, as long as the GIL is free. Run it from the
main thread (signal handlers can only be installed there).
Containers, Kubernetes and permissions
- Attaching (py-spy, gdb,
pdb -p,asyncio ps) needsCAP_SYS_PTRACEor root in the same PID namespace. Kubernetes: an ephemeral debug container (kubectl debug -it POD --image=... --target=CONTAINER) with the capability, or bakeinstall()/faulthandler into the app sokill -USR1 1is enough. - PID 1 in a container ignores signals it has no handler for:
kill -USR1 1works only afterregisterorinstall(); usetini/dumb-initas PID 1 so SIGTERM reaches Python. kubectl logsshows dumps written to stderr; write to a file on a volume if logs are rotated aggressively.
OS-level evidence
ps -o pid,ppid,pgid,stat,pcpu,nlwp,etime,cmd -p 12345 # state D = uninterruptible IO, S = sleeping
cat /proc/12345/status | grep -E "State|Threads|VmRSS"
cat /proc/12345/task/*/wchan; echo # kernel wait channel per thread (futex_wait...)
ls -l /proc/12345/fd | wc -l # fd leak? sockets piling up?
ss -tanp | grep 12345 # connections stuck in ESTABLISHED/CLOSE_WAIT
strace -f -p 12345 -e trace=futex,read,recvfrom,epoll_wait # what each thread waits on (Linux)
pstree -p 12345 # children and grandchildrenfutex waits are lock/condition waits; epoll_wait with no events is an idle event loop (asyncio
deadlock or waiting on a silent server); read/recvfrom on a socket is an IO hang; state D means the
kernel is stuck on IO (NFS, a dead disk, a GPU driver) and no Python tool can help.
python-asyncio-pattern-advisor
Recommend, implement and review Python asyncio patterns: async vs threads vs processes (IO vs CPU-bound), coroutines vs tasks vs futures, TaskGroup, gather and return_exceptions, wait and as_completed, Semaphore and Queue worker pools with backpressure, asyncio.timeout vs wait_for, cancellation, shield and cleanup, token-bucket RPM/TPM rate limits for LLM APIs, retries with backoff and jitter, async generators and SSE token streaming, to_thread/run_in_executor, background task references, eager tasks, asyncio.run/Runner, uvloop, Jupyter, FastAPI async vs def routes, httpx.AsyncClient lifespan, async DB/vector-store clients, debug mode and pytest-asyncio.
python-beginner-concept-tutor
Teach Python concepts to beginners heading into AI engineering (data scientists, analysts, students, career switchers): plain-language definitions, analogies, minimal examples, line-by-line walkthroughs, common mistakes, check-your-understanding questions and mini exercises; Socratic hints before solutions; explaining tracebacks; mutable vs immutable, references and aliasing, is vs ==, integer division, floating point, slicing and off-by-one, indentation errors; a staged learning path from variables to calling an LLM API, embeddings and a tiny RAG. Ships a script that lists the concepts a snippet uses in learning order and traces execution step by step like Python Tutor.
python-comprehension-refactorer
Refactor Python loops into list, dict and set comprehensions and generator expressions, and unroll comprehensions that are too clever: filtering, mapping, nested-loop flattening, conditional expressions, walrus in comprehensions, dict inversion, grouping with defaultdict/Counter, lazy generators for sum/any/all/max, streaming large files, itertools (chain, islice, groupby, batched, pairwise, accumulate, product), scoping rules, side-effect anti-patterns and performance (when map/filter or numpy/pandas vectorization win). AI-engineering focus: building prompt lists, batching texts for embeddings, filtering retrieved chunks by score, id->embedding dicts, flattening tool-call results, lazy dataset streaming and token counting.
python-context-manager-writer
Write correct Python context managers and find resource-handling bugs — __enter__/__exit__ semantics and exception suppression, contextlib (contextmanager, asynccontextmanager, ExitStack, AsyncExitStack, closing, aclosing, suppress, redirect_stdout, nullcontext, chdir), async with, re-entrancy, leaked files/connections/clients/pools/locks, and AI-engineering recipes: torch.no_grad/inference_mode/autocast, eval()/train() switches, GPU cache cleanup, token and latency meters, tracing spans, temp env vars, rate-limit semaphores, httpx/aiohttp client lifecycle, FastAPI lifespan, DB/vector-store transactions, atomic checkpoint writes, seeds and agent sandboxes.
python-dataclass-converter
Convert boilerplate Python classes, dicts, tuples and JSON payloads into dataclasses, or into pydantic v2 BaseModel, TypedDict, NamedTuple, attrs or msgspec when that fits better. Covers every @dataclass option (frozen, slots, kw_only, order, unsafe_hash, match_args, weakref_slot), field() (default_factory, init=False, repr, compare, hash, metadata), __post_init__, InitVar, KW_ONLY, inheritance and default ordering, replace(), asdict deep-copy pitfalls, hashing/equality, serialization and validation. AI focus: LLM request/response and chat Message types, RAG chunks with embeddings (numpy eq gotcha), eval records, configs, typed OpenAI/Anthropic JSON.
python-exception-handling-designer
Design and review Python exception handling: custom exception hierarchies with retryable flags, what to catch where, EAFP vs LBYL, chaining with raise ... from, else/finally, logger.exception, add_note, ExceptionGroup and except* with asyncio.TaskGroup, warnings, asserts vs validation, FastAPI error translation, retries with backoff and jitter, timeouts, cancellation, pytest.raises, plus LLM-app failures: 429 rate limits with retry-after, 5xx overloaded, context-length exceeded, content filters, broken streams, malformed JSON repair loops, tool-call errors fed back to the model, partial embedding batch failures, CUDA OOM batch halving, vector DB outages, circuit breakers and model fallbacks.