Skip to content

[DO NOT MERGE] Probe hang stack dump against a live Kit process - #7153

Draft
mataylor-nvidia wants to merge 4 commits into
isaac-sim:developfrom
mataylor-nvidia:mataylor/hang-stack-dump-ci-probe
Draft

[DO NOT MERGE] Probe hang stack dump against a live Kit process#7153
mataylor-nvidia wants to merge 4 commits into
isaac-sim:developfrom
mataylor-nvidia:mataylor/hang-stack-dump-ci-probe

Conversation

@mataylor-nvidia

Copy link
Copy Markdown

Description

Do not merge. Throwaway CI probe for #7152, branched off it. Revert by deleting the marked block and the _probe_wedge() call.

What this proves

#7152 adds three regression tests that spawn a genuinely hung child and assert the stack dump appears. Those cover the mechanism, but the child is a toy python process with one thread.

What they cannot cover is the part that actually matters here: does a real Isaac Sim process answer SIGUSR1? Kit runs dozens of native threads, installs its own signal handling, and parks the main thread inside C++ calls that never return to the interpreter loop. If Kit blocked or claimed SIGUSR1, the feature would pass CI and still be useless on the hangs it was built for.

This probe wedges test_rendering_cartpole.py after its first case has really rendered — so Kit is fully initialized, has produced actual frames, and all its threads are live at the moment the signal arrives.

Note the injection is a hang, not a kill. Killing Kit produces a crash, and crashes already printed stacks before #7152PYTHONFAULTHANDLER=1 covers SIGSEGV/SIGABRT. The gap being closed is specifically hangs.

Expected result

The rendering-correctness job should show test_rendering_cartpole.py killed for timeout, with both the job log and the JUnit report carrying:

=== HANG STACK DUMP (all threads) ===
----- dump 1 of 2 -----
Current thread 0x... (most recent call first):
  File ".../threading.py", line 355 in wait
  File ".../test_rendering_cartpole.py", line ... in _probe_wedge
  File ".../test_rendering_cartpole.py", line ... in test_rendering_cartpole
  ...
Thread 0x... (most recent call first):
  <Kit's own threads>

Two things to check:

  1. _probe_wedge is named, and the frames below it are the real pytest/Kit call chain — the diagnostic that did not exist before.
  2. Kit's other threads appear as additional Thread 0x... blocks. That is the evidence faulthandler.register walks a live Kit process, not just the main thread.

The two dumps should be identical, which is the intended "wedged, not slow" signal.

Cost and blast radius

test_rendering_cartpole.py carries no PER_TEST_TIMEOUTS entry, so it runs on DEFAULT_TIMEOUT (1000 s) plus the 700 s cold-cache buffer it earns as the first enable_cameras=True file — roughly 28 minutes before the dump fires, against the job's 120-minute budget. Left at the defaults deliberately so the probe exercises the real configuration.

The orchestrator runs each file in its own process, so only test_rendering_cartpole.py is affected; the other six rendering files in the job run normally afterward. test-rendering-correctness is continue-on-error on pull requests, so a red result here does not block.

Before

The same hang on develop produces:

=== SYSTEM DIAGNOSTICS ===
--- nvidia-smi ---
...
--- process tree (ps auxf) ---

No stack, anywhere. That is the gap #7152 closes.

Type of change

  • This change requires a documentation update

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

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.
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.
Blocks test_rendering_cartpole.py forever after its first case has really
rendered, so CI exercises the hang stack dump against a live Isaac Sim
process rather than the toy subprocess the unit tests use. Kit is fully
up and all of its threads are running when SIGUSR1 arrives, which is the
part the unit tests cannot cover.

Expect the file to be killed for "timeout" and its report to carry a
HANG STACK DUMP section naming _probe_wedge. Not for merge.
@mataylor-nvidia
mataylor-nvidia force-pushed the mataylor/hang-stack-dump-ci-probe branch from 13f9fad to 774826b Compare August 19, 2026 16:28
@mataylor-nvidia

Copy link
Copy Markdown
Author

Run 1 result: the probe worked, and it found a real bug

The wedge landed exactly where intended, and the hang was detected on schedule — but no stack dump was produced:

22:41:24  collected 33 items
22:41:48  test_rendering_cartpole[physx-isaacsim_rtx-rgb] PASSED
23:05:02  test_rendering_cartpole[physx-isaacsim_rtx-albedo]      <- wedged mid-case
          Test .../test_rendering_cartpole.py timed out after 1700 seconds...
          --- nvidia-smi ---

nvidia-smi follows the timeout line directly, so pre_kill_diag was empty.

Root cause

pytest captures at the file-descriptor level. It dup2s fd 2 to a temp file, so
faulthandler.register(file=sys.__stderr__) — which stores fileno() = 2 — wrote the dump into pytest's
capture buffer, which is discarded when the process is SIGKILLed. That is the only case the dump is ever
written in.

The decisive evidence is this probe's own [CI PROBE] wedging marker: a plain print(..., file=sys.__stderr__)
with no signal involved, and it never reached the log either. So fd 2 is captured regardless of the dump path.

The regression tests in #7152 missed this because they hung a bare python script.py child, which has no
capture, so the dump reached stderr and they passed.

Fix

#7152 now writes the dump to a file named by ISAACLAB_HANG_DUMP, set per test file and cleared per attempt,
mirroring the crash journal's ISAACLAB_TEST_JOURNAL — and for the same reason tools/ovrtx_log.py keeps the
renderer log in a file. Its tests now hang a real python -m pytest child; against the previous
implementation all three fail on assert 'HANG STACK DUMP' in ''.

Also added the isaaclab_tasks changelog fragment this branch was missing.

Re-running to confirm the dump appears.

Worth noting

Two things did work on run 1: the timeout fired at exactly 1700 s (1000 DEFAULT_TIMEOUT + 700 cold-cache
buffer) as predicted, and the crash journal recovered all 33 verdicts from the killed process
(recovered 33 test result(s) from the crash journal).

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