A deadlocked test does not fail; it stops. The job runs to its platform limit, gets killed, and the log ends mid-sentence with no indication of which test was running or what it was waiting for. faulthandler closes that gap for the cost of one configuration line: after a chosen number of seconds with no progress, every thread's stack is written to stderr, and the resulting dump usually contains the complete diagnosis.
Prerequisites
- Python 3.3+;
faulthandleris in the standard library and pytest enables it by default. pytest >= 8.0, pluspytest-timeoutfor the failure half of the arrangement.- A suite with real threads or subprocesses — a single-threaded hang is better served by pdb.
Solution
Arm faulthandler just below the suite's own ceiling so the dump lands before anything kills the process.
# pyproject.toml
[tool.pytest.ini_options]
# Dump every thread's stack after 45 s of a single test making no progress.
faulthandler_timeout = 45
# And fail the test at 60 s, giving the dump 15 s of headroom to be written.
timeout = 60
timeout_method = "thread"
# conftest.py — an on-demand dump for local reproduction
import faulthandler
import signal
def pytest_configure(config):
# kill -USR1 <pid> now prints every thread's stack without stopping anything.
if hasattr(signal, "SIGUSR1"):
faulthandler.register(signal.SIGUSR1, all_threads=True, chain=True)
Timeout (0:00:45)!
Thread 0x00007f2ab3fff700 (most recent call first):
File "app/cache.py", line 41 in refresh
File "app/cache.py", line 88 in get
File "threading.py", line 975 in _bootstrap_inner
Current thread 0x00007f2ac4a1e740 (most recent call first):
File "app/cache.py", line 62 in evict
File "app/cache.py", line 91 in put
File "tests/test_cache.py", line 24 in test_concurrent_eviction
Why this works
faulthandler.dump_traceback_later(timeout, exit=...) starts a watchdog thread that sleeps for the timeout and, if nothing cancelled it, writes the Python stack of every thread using only pre-allocated buffers and async-signal-safe calls. That constraint is why it works when nothing else does: it does not allocate, does not take the GIL, and therefore produces output even when every other thread is blocked.
pytest wires this to the faulthandler_timeout option, resetting the watchdog before each test so the countdown measures a single test rather than the whole run. pytest-timeout is doing something different — failing the test — and the two are complementary rather than redundant.
Edge cases and failure modes
faulthandler_timeoutset above the suite timeout. The process is killed before the dump is written, which is the same as having no dump at all. It must be lower.- A dump with only one thread. The hang is not a deadlock between threads; look at what that thread is blocked on — a socket read, a
queue.getwith no timeout, a subprocess that never exits. - Output swallowed by capture.
faulthandlerwrites to the real file descriptor 2, so it survives pytest's capture, but a CI system that buffers job output may still delay it.-sguarantees it is interleaved in order. - C-level frames missing. The dump shows Python frames only. A thread blocked inside a C extension shows the Python call that entered it, which is usually enough to identify the library.
exit=Truewhen called manually.dump_traceback_later(30, exit=True)aborts the process after dumping, which is right for a standalone script and wrong inside pytest, where it kills the whole session.
Which timeout mechanism to use where
Three mechanisms can end a hung test and they produce very different evidence, so the combination matters more than any one of them.
pytest-timeout with timeout_method = "signal" raises inside the running test at the exact line, producing an ordinary pytest failure with a real traceback and letting the suite continue. It works only on the main thread of a Unix process, so a suite with worker threads or a Windows runner cannot rely on it.
timeout_method = "thread" runs a watchdog that dumps every thread's stack and then kills the process. It works everywhere and gives good evidence, but the session ends — remaining tests do not run.
faulthandler_timeout dumps without ending anything, which is why it belongs below whichever of the two above is configured: the stacks are written first, and the failure mechanism then does its job.
For most suites the right combination is faulthandler_timeout plus the thread method, because a suite that hangs is usually going to need a human anyway and the complete stacks are worth more than the remaining tests. Where the suite is long and a single hang should not cost the whole run, the signal method keeps the session alive at the cost of covering only the main thread — acceptable when the threads are library-owned and the main thread is where the test's own code blocks. Whichever pair is chosen, write the two numbers down next to each other in the configuration with a comment, because the relationship between them — dump first, fail second — is the part that breaks silently when somebody later raises one of them in isolation.
Reading the dump
The dump is a list of threads, each with its frames innermost-first. Three questions answer almost every case.
What is each thread blocked on? The innermost frame names it: acquire on a lock, wait on a condition, recv on a socket, join on another thread. A thread whose innermost frame is ordinary application code is not blocked — it is spinning, which is a different bug.
What does each blocked thread already hold? Walk outward until you find the with self._lock: that thread is inside. The dump does not say so directly, but the frame at that line tells you which lock the thread acquired on its way in.
Is there a cycle? Thread A holds lock 1 and waits for lock 2; thread B holds lock 2 and waits for lock 1. That is the deadlock, and the fix is a global acquisition order rather than more locks.
Thread 0x…700 (most recent call first):
File "threading.py", line 327 in wait ← blocked here
File "app/cache.py", line 41 in refresh ← inside `with self._write_lock`
File "app/cache.py", line 88 in get
Current thread 0x…740 (most recent call first):
File "threading.py", line 327 in wait ← blocked here
File "app/cache.py", line 62 in evict ← inside `with self._index_lock`
File "app/cache.py", line 91 in put
Two threads, two locks, opposite order: a complete diagnosis from a dump that cost one configuration line. The repair is to establish that _index_lock is always taken before _write_lock, everywhere, which is a rule a reviewer can check by reading rather than a race anyone has to reproduce.
Dumping a process you did not start
The signal registration above pays off when the hang happens somewhere you cannot easily add configuration: a long-running local reproduction, a worker inside a container, a process a colleague is looking at.
# Find the stuck process and ask it for a dump. Nothing is killed.
pgrep -af "pytest tests/test_cache.py"
kill -USR1 12345 # stacks appear on the process's stderr
# Inside a container, the same signal via the runtime:
docker kill --signal=SIGUSR1 my-test-container
Because faulthandler.register is armed at pytest_configure, every test session in the repository responds to the signal, including ones started by an IDE. That makes "send USR1 and read the stacks" the first move against any hang, ahead of attaching a debugger — it is faster, it is non-invasive, and it works on a process whose environment cannot be changed.
Two arguments to register matter. all_threads=True is what makes it useful; the default dumps only the current thread, which for a deadlock is the least interesting one. And chain=True preserves any handler already installed for that signal, so registering does not silently break something else that used SIGUSR1.
Frequently Asked Questions
Why does my deadlocked test produce no output at all?
Because nothing asked for one. pytest's own timeout kills the process, and a CI platform's job timeout kills it harder. faulthandler_timeout set below the suite timeout writes every thread's stack while the process is still alive, which is the only window in which the dump can be produced.
Does faulthandler work with pytest-timeout?
They complement each other. Set faulthandler_timeout lower than pytest-timeout's value so the stacks are written first and the timeout then fails the test cleanly. pytest-timeout's thread method also dumps stacks, so on Unix the signal method plus faulthandler often gives the most readable combination.
Can I dump stacks without waiting for a timeout?
Yes. faulthandler.register(signal.SIGUSR1) lets you send a signal to a stuck process and get a dump immediately, which is invaluable when reproducing a hang locally. It also works against a process running under a debugger or inside a container.
Related
- Testing Threads & Race Conditions — the lock-ordering discipline that prevents the cycle in the first place.
- Failing Fast with pytest-timeout — the layer that turns the hang into a reported failure.
- Tracking Down a Hung await with Task Stacks — the asyncio equivalent, where the stacks live on tasks rather than threads.
- Capturing Artifacts from a Failed CI Test Run — making sure the dump survives the job.
← Back to Testing Threads & Race Conditions