Skip to content

Dump thread stacks before killing a hung test - #7152

Open
mataylor-nvidia wants to merge 2 commits into
isaac-sim:developfrom
mataylor-nvidia:mataylor/hang-stack-dump
Open

Dump thread stacks before killing a hung test#7152
mataylor-nvidia wants to merge 2 commits into
isaac-sim:developfrom
mataylor-nvidia:mataylor/hang-stack-dump

Conversation

@mataylor-nvidia

@mataylor-nvidia mataylor-nvidia commented Aug 18, 2026

Copy link
Copy Markdown

Description

A test that crashes reports a traceback, because PYTHONFAULTHANDLER=1 installs faulthandler for SIGSEGV and friends. A test that hangs reports nothing.

Hang detection already works — tools/conftest.py catches three kinds:

Kind Trigger Constant
startup_hang no AppLauncher initialization complete / collected marker STARTUP_DEADLINE = 120 s
timeout wall clock exceeds the per-file budget DEFAULT_TIMEOUT = 1000 s
shutdown_hang JUnit report written, process still alive SHUTDOWN_GRACE_PERIOD = 30 s

The problem is what happens next: all three escalate straight to os.killpg(pgid, SIGKILL). SIGKILL cannot be caught, so nothing gets a chance to dump. The report carries nvidia-smi, ps auxf, dmesg and the last -v test name — nothing pointing at the hung code.

The repo already records the symptom in a skip reason: "Native hang: the per-file CI runner kills the suite after 1000s with no pytest outcome" (source/isaaclab_tasks/test/rendering_test_utils.py).

This complements the crash journal (#7005): that recovers which tests had passed when a process died; this reports where the process is stuck.

Change

The runner asks the process where it is stuck before killing it.

  • tools/hang_dump.py (new) — pytest plugin registering SIGUSR1 via faulthandler.register(), writing to a file named by ISAACLAB_HANG_DUMP, which the runner sets per test file and clears per attempt, mirroring the crash journal's ISAACLAB_TEST_JOURNAL. No-ops where the signal does not exist.

    The dump has to go to a file, not stderr. pytest captures at the file-descriptor level, so it has already redirected fd 2 by the time the plugin loads; a dump written there is discarded when the process is SIGKILLed — the only case it is ever written in. The first revision of this PR wrote to sys.__stderr__ and produced no dump in CI at all, which [DO NOT MERGE] Probe hang stack dump against a live Kit process #7153 caught. tools/ovrtx_log.py keeps the renderer log in a file for the same reason.

  • conftest.py — loads it via pytest_plugins, covering every suite.

  • tools/conftest.py_dump_hung_process_stacks() signals the process and drains its output before the existing SIGKILL, prepending the result to pre_kill_diag. The fd-drain block was extracted into _drain_ready_output() so the watchdog loop and the dump path share one implementation. pre_kill_diag is now also threaded into _make_missing_report_result, so a fresh-process retry that hangs reports its stack too.

The dump is taken twice — identical stacks seconds apart are what distinguish a wedged process from a slow one.

Report plumbing is otherwise unchanged: pre_kill_diag already flows into the startup_hang and timeout reports and the retry warnings, and the drain echoes to stdout/stderr, so the stack also streams live to the job log. Prepending rather than appending matters — _get_diagnostics truncates with diag[:10000], so the stack survives and the system tables get trimmed instead.

Why SIGUSR1

SIGTERM and SIGABRT are unusable here. AppLauncher binds both to _on_abort_signal, which calls SimulationApp.close() — itself what a shutdown hang is stuck inside — so either would re-enter the hang. Binding SIGABRT also displaces faulthandler's own handler.

A Python-level signal handler would not run regardless: those execute between bytecodes, and a thread wedged in a native Kit, CUDA, or renderer call never returns to the interpreter loop. isaaclab.cli.multigpu documents the same constraint when reaping stragglers.

faulthandler.register() installs a C-level handler that walks every thread from inside the signal handler, so it reports a process whose GIL will never be released. SIGUSR1 is unused anywhere in source/, tools/, scripts/, .github/.

Sample output

Against a process blocked in threading.Event().wait():

=== HANG STACK DUMP (all threads) ===
----- dump 1 of 2 -----
Current thread 0x000073f05cc49080 (most recent call first):
  File "/usr/lib/python3.12/threading.py", line 355 in wait
  File "<string>", line 8 in wedged_call

Type of change

  • Bug fix (non-breaking change which fixes an issue)

Screenshots

Not applicable.

Checklist

  • I have run the pre-commit checks with ./isaaclab.sh --format
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective or that my feature works
  • I have updated the changelog and the corresponding version in the extension's config/extension.toml file
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

Testing

Three regression tests added to test_test_orchestrator_result_handling.py, exercising the real capture_test_output_with_timeout against a genuinely hung child. Confirmed they fail without the change on the meaningful assertion (assert 'HANG STACK DUMP' in '', assert 0 > 1), not on a missing constant.

14 passed on Linux; the three new ones skip on Windows, where the orchestrator's process handling (select on pipes, os.killpg, start_new_session) is unavailable.

#7153 is a throwaway probe branched off this one, wedging a rendering correctness test so CI exercises the dump against a live Kit process with all its threads running.

Scope

Deliberately excluded:

  • No native C++ frames (no py-spy/gdb). Python stacks stop at the C boundary, so a Kit shutdown hang reads as _close_appSimulationApp.close() without naming the RTX/PhysX call. Still localizes the hang to a test and a call site.
  • No change to pass/fail classificationpassed (shutdown hanged) stays a pass, so nothing goes red as a side effect.
  • Still uncovered: docker wait in run_tests.sh has no deadline, so a container hanging above pytest is caught only by the 180-minute job timeout.

A test that crashes reported a traceback, because PYTHONFAULTHANDLER=1
installs faulthandler for SIGSEGV and friends. A test that hung reported
nothing: the runner detects the hang and kills the process group with
SIGKILL, which cannot be caught, so no handler ever ran. The report
carried system tables and the last -v test name, and nothing that points
at the hung code.

The runner now asks the process where it is stuck before killing it.
tools/hang_dump.py registers SIGUSR1 with faulthandler.register, and
capture_test_output_with_timeout signals the process and drains the dump
into pre_kill_diag, which already flows into the startup_hang, timeout,
and shutdown_hang reports. The dump is taken twice: identical stacks
seconds apart are what tell a wedged process from a slow one.

SIGTERM and SIGABRT cannot be used for this. AppLauncher binds both to a
handler that calls SimulationApp.close(), which is itself what a shutdown
hang is stuck inside, so either would re-enter the hang. A Python-level
signal handler would not run regardless, since those execute between
bytecodes and a thread wedged in a native Kit, CUDA, or renderer call
never returns to the interpreter loop. faulthandler.register installs a
C-level handler that walks every thread from inside the signal handler,
so it reports a process whose GIL will never be released.
@greptile-apps

greptile-apps Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR adds an on-demand faulthandler stack-dump plugin and asks hung pytest children for two thread dumps before the orchestrator kills their process group.

  • Registers a POSIX SIGUSR1 handler through the repository-wide pytest configuration.
  • Refactors pipe draining so watchdog and stack-dump collection share the same implementation.
  • Propagates pre-kill diagnostics into missing-report retry results.
  • Adds POSIX regression coverage for stack contents, repeated dumps, diagnostic ordering, and unsupported platforms.

Confidence Score: 5/5

The PR appears safe to merge, with no concrete blocking or independently actionable non-blocking defect identified.

The new signal handler is installed before the repository’s ordinary test and Kit startup paths, the orchestrator drains stack output before its existing process-group kill, and the diagnostic data is correctly propagated through the changed missing-report paths.

Important Files Changed

Filename Overview
tools/conftest.py Adds repeated pre-kill stack collection, shared nonblocking output draining, and missing-report diagnostic propagation without changing result classification.
tools/hang_dump.py Adds a platform-aware pytest plugin that registers a C-level SIGUSR1 faulthandler writing all thread stacks to the original stderr.
conftest.py Loads the new hang-dump plugin for repository pytest suites.
source/isaaclab/test/cli/test_test_orchestrator_result_handling.py Adds integration-style tests using a genuinely blocked child process and an unsupported-platform no-op test.

Sequence Diagram

sequenceDiagram
    participant Runner as Test orchestrator
    participant Child as Pytest child
    participant Handler as faulthandler
    participant Report as Diagnostics/report
    Child->>Handler: pytest_configure registers SIGUSR1
    Runner->>Child: Monitor startup, timeout, and shutdown
    Runner->>Runner: Detect hang and capture system diagnostics
    loop Two dump passes
        Runner->>Child: SIGUSR1
        Handler-->>Runner: All Python thread stacks on stderr
        Runner->>Runner: Drain and stream stdout/stderr
    end
    Runner->>Child: SIGKILL process group
    Runner->>Report: Prepend stack dump to diagnostics
Loading

Reviews (1): Last reviewed commit: "Dump thread stacks before killing a hung..." | Re-trigger Greptile

@isaaclab-review-bot isaaclab-review-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isaac Lab Review Bot

The SIGUSR1/faulthandler approach and diagnostic plumbing are coherent, but the stack-dump size limit conflicts with the downstream 10,000-character report limit and can remove the second dump and existing system diagnostics from JUnit output.

  • Design and architecture: The plugin-based signal handler and child-PID signalling fit the existing test-runner architecture. The remaining design issue is that a dump allowed to reach 64 KiB is prepended to a diagnostic field capped at 10,000 characters, despite that field also carrying system diagnostics and two dumps being central to the design.
  • API: No public or extension-facing API compatibility issue was identified. The existing six-element capture result remains unchanged, and the added private helper parameter preserves prior behavior through its default.
  • Implementation: The output-draining extraction preserves the existing streaming behavior, and unsupported signal platforms are guarded. However, HANG_DUMP_LIMIT_BYTES must be aligned with the downstream _get_diagnostics limit so both requested dumps and useful system diagnostics can survive in generated reports.

Minor fixes needed. Posted 1 actionable finding inline.

Automated review; human maintainers own approval decisions.

Comment thread tools/conftest.py
that are already failing.
"""

HANG_DUMP_LIMIT_BYTES = 64 * 1024

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Warning · Implementation — Dump budget exceeds report truncation limit

HANG_DUMP_LIMIT_BYTES permits 64 KB, but the prepended pre_kill_diag is consumed by _get_diagnostics, which cuts at diag[:10000]. For a Kit process with many threads the dump alone exceeds that, so this cap never fires, the second dump is cut off, and the pre-existing nvidia-smi/ps auxf/dmesg sections are dropped entirely from timeout and missing-report entries. Cap the dump section well below 10000 characters so both survive.

The dump never reached CI. pytest captures at the file-descriptor level, so
it has already pointed fd 2 at a temporary file of its own by the time the
plugin loads; faulthandler.register(file=sys.__stderr__) stored fd 2 and
wrote there. That buffer is discarded when the process is SIGKILLed, which
is the only case the dump is ever written in, so a hung test still reported
nothing but system tables.

The dump now goes to a file named by ISAACLAB_HANG_DUMP, which the runner
sets per test file and clears per attempt, mirroring the crash journal's
ISAACLAB_TEST_JOURNAL. pytest does not redirect it, and the runner reads it
after the process is gone. This is the same reason tools/ovrtx_log.py keeps
the renderer log in a file.

The regression tests missed this because they hung a bare `python script.py`
child, which has no capture, so the dump reached stderr and they passed.
They now hang a real `python -m pytest` child, reproducing the CI failure:
against the previous implementation all three fail on
`assert 'HANG STACK DUMP' in ''`.

Found by the CI probe in the follow-up branch, which wedged a rendering
correctness test and produced a timeout report with no stack.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

infrastructure isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant