Time the hang watchdog from the kernel, so disarming it cannot wedge the process (#661) - #684
Conversation
…the process (#661) unwatch() could hang or crash the process it exists to diagnose. Native stack, 2331 of 2331 samples in one place: the main thread inside cancel_dump_traceback_later() holding the GIL, the faulthandler C thread pinned in _Py_DumpTracebackThreads walking live frames without it, and the Timer thread starved in take_gil. Load is the trigger, not xdist -- idle 0 hangs in 12 runs, 1 of 1 under burners, and 6/13 with xdist against 6/13 without. One defect, two faces: walking frames another thread is mutating SEGVs the xdist worker in CI and wedges the process locally. It failed six unrelated PRs in a day. The mechanism is now a kernel interval timer and a signal handler. signal.setitimer raises SIGALRM whether or not the interpreter can run; faulthandler's registered handler dumps on the thread that receives it; disarming is setitimer(0), a syscall that cannot wait on anything. Nothing runs concurrently with the interpreter, so the deadlock is removed structurally rather than avoided. dump_traceback_later and cancel_dump_traceback_later are gone from the watchdog entirely. Measured against the bar the old mechanism set, before changing the library: 200 tight arm/disarm cycles -- the pattern that wedges today -- clean; 8 of 8 expected dumps on every rank blocked in a 4 s allreduce; 300 rounds of allreduce/barrier/bcast under a 100 Hz timer with zero collective errors, so the signal does not disturb the traffic it watches. abort was implemented ONLY through dump_traceback_later(exit=True), so removing that would have silently broken UW_HANG_WATCHDOG_ABORT, which CI depends on and which must work on a rank blocked in MPI where no Python handler runs. It is now done by the kernel too: SIGALRM's disposition is set to SIG_DFL and the dump chains to it, so the process dumps and is then terminated by the signal. Verified at np=4 -- each blocked rank wrote one dump naming reduce_the_count and died on signal 14, the job ending in 2 s instead of hanging. The dump FORMAT changes, and that matters because the dumps are a parsed artefact: dump_traceback_later writes a "Timeout (" header before each dump and a signal dump does not. hang_report keyed on that header, so every dump in a file would have merged into one. It now starts a new dump at the "Current thread" line, which faulthandler writes exactly once per dump in both formats, so headed files parse exactly as before and headerless ones parse correctly. tests/test_0053_hang_watchdog.py + test_0054_hang_report.py: 19 passed in 19.25 s, against an 18.25 s control for test_0054 alone on unmodified development. The 005x/006x batch: 74 passed. Flagged, not fixed (Charter S9): test_0053's module docstring points at tests/parallel/test_0778_hang_watchdog_mpi.py, which does not exist -- no test outside these two files uses the watchdog. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
There was a problem hiding this comment.
🟡 Changes recommended
The new SIGALRM-based abort path in mpi.py can permanently clobber any pre-existing SIGALRM handler (and repeated re-registering per checkpoint is unnecessarily invasive), so signal state should be preserved/restored to avoid process-wide side effects.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR updates the hang watchdog to avoid deadlocks/crashes caused by faulthandler.dump_traceback_later() by switching watchdog timing to a kernel interval timer (setitimer/SIGALRM) and adjusting hang-dump parsing/counting to handle headerless signal-triggered dumps.
Changes:
- Rework watchdog arming/disarming to use
SIGALRM+signal.setitimer()instead ofdump_traceback_later()/cancel_dump_traceback_later(). - Update dump parsing (
hang_report.py) and test dump counting (test_0054) to split dumps using theCurrent thread ...line (works for both headed and headerless dumps).
File summaries
| File | Description |
|---|---|
src/underworld3/mpi.py |
Switch watchdog timing to kernel SIGALRM interval timer and dump via faulthandler’s signal handler. |
src/underworld3/utilities/hang_report.py |
Treat Current thread ... as a dump boundary so repeated signal dumps don’t merge. |
tests/test_0054_hang_report.py |
Count dumps using Current thread markers instead of the Timeout ( header. |
Review details
Suppressed comments (1)
src/underworld3/mpi.py:474
cancel()disables the interval timer and unregisters SIGALRM, but in theabort=Truepatharm()has already overwritten the prior SIGALRM handler withSIG_DFL(sounregister()will restoreSIG_DFL, not the original handler). If you save the previous handler inarm(), restore it here sounwatch()leaves process-wide signal state unchanged.
if _INTERVAL_TIMER_AVAILABLE:
_signal.setitimer(_signal.ITIMER_REAL, 0.0, 0.0)
_faulthandler.unregister(_signal.SIGALRM)
- Files reviewed: 3/3 changed files
- Comments generated: 1
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| if _INTERVAL_TIMER_AVAILABLE: | ||
| # `abort` has to work on a rank blocked inside MPI, where no | ||
| # Python-level handler runs, so it is done by the kernel too: the | ||
| # C handler dumps and then CHAINS to SIGALRM's default action, | ||
| # which is to terminate. Without abort there is nothing to chain | ||
| # to and the dump simply repeats. | ||
| if self.abort: | ||
| _signal.signal(_signal.SIGALRM, _signal.SIG_DFL) | ||
| _faulthandler.register(_signal.SIGALRM, file=self.stream, | ||
| all_threads=True, chain=self.abort) | ||
| _signal.setitimer(_signal.ITIMER_REAL, self.seconds, self.seconds) |
…tate back Three defects from the review of the first commit -- one Copilot's, two found by executing its claim rather than accepting it. 1. unwatch() did not restore a pre-existing SIGALRM handler (Copilot). With abort, arm() installed SIG_DFL underneath faulthandler so the dump could chain to it and terminate; faulthandler.unregister then restored that SIG_DFL rather than the caller's handler. The previous disposition is now remembered and put back. 2. checkpoint() from a worker thread raised ValueError with abort on, which is CI's setting. signal.signal refuses to run outside the main thread, and the signal setup was being redone on every arm() -- and checkpoint() is arm(). Measured against development, which returns "ok". The setup now happens once when the watchdog is built. 3. watch() consumed a user's ITIMER_REAL in silence. There is one interval timer per process and the watchdog needs it, so it cannot be shared; it now warns rather than cancelling someone's timer without saying so. Copilot's other claim, that re-registering per checkpoint is "unnecessarily invasive", does not survive measurement: checkpoint() costs 41.8 us here against 66.9 us on development, so it is cheaper than what it replaced. Fixing 2 by moving registration into __init__ then broke the resume path, and the whole-file run is what caught it: `watching` cancels the outer watchdog and restores it with arm(resume=True), which moved the clock without re-registering the handler. The next SIGALRM reached the SIG_DFL underneath and the kernel killed pytest -- seven tests in, no summary. Registration is now re-established whenever a watchdog is armed without one. Regression tests for all three, in test_0053: the handler is put back, a checkpoint from a worker thread survives with abort on, and taking the interval timer is announced. tests/test_0053 + test_0054: 22 passed in 19.03 s. tests/test_005*, test_006*: 77 passed. Underworld development team with AI support from Claude Code Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv
Adversarial review of this PR, combined with Copilot'sCopilot raised one issue. Executing it rather than accepting it turned up two 1.
|
checkpoint() |
|
|---|---|
| this branch | 41.8 us |
development |
66.9 us |
It is cheaper than the mechanism it replaces. The re-registering was worth
removing for reason 2, not for cost.
What the fix for 2 then broke, and how it was caught
Moving registration into __init__ broke the resume path. watching cancels
the outer watchdog and restores it with arm(resume=True), which then moved the
clock without re-registering the handler — so the next SIGALRM reached the
SIG_DFL underneath and the kernel killed pytest. Seven tests in, no summary,
exit 1.
That is the failure this whole PR exists to prevent, reintroduced by the fix for
a review comment. It was caught only by running the whole file rather than the
new tests, which is worth recording. Registration is now re-established whenever
a watchdog is armed without one, and test_watching_restores_the_previous_watchdog
covers it.
Verification after the fixes
test_0053 + test_0054 |
22 passed in 19.03 s |
test_005* + test_006* |
77 passed |
| Charter scan | clean |
A: checkpoint() off-thread, abort=True |
ok |
B: pre-existing SIGALRM handler after unwatch() |
restored |
C: taking ITIMER_REAL |
announced by RuntimeWarning |
Summary
Closes #661.
unwatch()could hang or crash the process it exists to diagnose. Native stack,2331 of 2331 samples in one place:
Load is the trigger, not xdist: idle 0 hangs in 12 runs, 1 of 1 under CPU
burners, and 6/13 with xdist against 6/13 without — that equality is what
ruled xdist out. One defect, two faces: walking frames another thread is
mutating SEGVs the xdist worker in CI and wedges the process locally. It failed
six unrelated PRs in a day.
The fix
The clock moves to the kernel and the dump to a signal handler.
signal.setitimerraises SIGALRM whether or not the interpreter can run;faulthandler's registered handler dumps on the thread that receives it; and
disarming is
setitimer(0), a syscall that cannot wait on anything. Nothingruns concurrently with the interpreter, so the deadlock is removed structurally
rather than dodged.
dump_traceback_laterandcancel_dump_traceback_lateraregone from the watchdog entirely.
Measured against the bar the old mechanism set, before the library was
touched:
allreduceTwo things this nearly broke
abortwas implemented only throughdump_traceback_later(exit=True).Removing that would have silently broken
UW_HANG_WATCHDOG_ABORT— which CIdepends on, and which has to work on a rank blocked in MPI where no Python-level
handler runs. It is now done by the kernel too: SIGALRM's disposition is set to
SIG_DFLand the dump chains to it, so the process dumps and is then terminatedby the signal. Verified at np=4: each blocked rank wrote one dump naming
reduce_the_countand died on signal 14, the job ending in 2 s instead ofhanging.
The dump format, which matters because the dumps are a parsed artefact.
dump_traceback_laterwrites aTimeout (header before each dump; a signaldump does not.
hang_reportkeyed on that header, so every dump in a file wouldhave merged into one. It now starts a new dump at the
Current threadline,which faulthandler writes exactly once per dump in both formats — headed
files parse exactly as before, headerless ones parse correctly.
This is what made
test_0054hang for ten minutes on the first attempt: thetest counted
"Timeout ("and waited forever for a dump count that could nolonger rise.
Verification
test_0053+test_0054test_0054on unmodifieddevelopmenttest_005*+test_006*batchreduce_the_count, killed on signal 14Flagged, not fixed (Charter §9)
test_0053's module docstring points attests/parallel/test_0778_hang_watchdog_mpi.py, which does not exist. No testoutside these two files uses the watchdog at all.
Underworld development team with AI support from Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01E87Q7KrpapxeQiLD1RiNXv