Skip to content

Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround#6636

Draft
hujc7 wants to merge 8 commits into
isaac-sim:developfrom
hujc7:jichuanh/fix-abort-handler-reentrancy
Draft

Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround#6636
hujc7 wants to merge 8 commits into
isaac-sim:developfrom
hujc7:jichuanh/fix-abort-handler-reentrancy

Conversation

@hujc7

@hujc7 hujc7 commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Consolidates AppLauncher process-lifecycle handling into one nested _SimulationAppLifecycle class: startup announcements (CI marker, Kit version diagnostics) plus the entire exit-path policy, with the policy table as the class docstring.
  • Fixes every exit path that misreported failure as success or destroyed its own diagnostics (full failure-case table below). Absorbs the atexit exit-code fix from Fix AppLauncher exception exit status #6634.
  • Documents the NCCL cuMem workaround for multi-GPU RTX training on NUMA-spanning GPU allocations (NCCL_CUMEM_HOST_ENABLE=0 first, NCCL_CUMEM_ENABLE=0 as fallback).
  • The two isaacsim-dependent pieces are marked WORKAROUND(isaac-sim) and fail loudly if upstream drift disables them; both become deletable once Preserve nonzero exit status through fast-shutdown close IsaacSim#717 lands.

Failure cases and how this PR addresses them

Root mechanism: Kit fast shutdown terminates the process with exit code 0 from inside SimulationApp.close(), so any death funneled through an unqualified close() is reported as success; additionally, the abort-signal handler was unguarded against re-entrancy.

How the process ends Before this PR After this PR
Unhandled Python exception atexit close overwrote the pending failure with exit 0 (CI false-green) exits 1 (sys.last_exc detected; absorbed from #6634; SystemExit documented as not yet covered)
Single SIGTERM (torchrun teardown, SLURM preemption, kill) graceful close → exit 0; launcher marks the killed rank SUCCEEDED; surviving ranks hang until the NCCL watchdog full teardown, then dies by the signal (128+signum)
Second signal while a close is running (repeated SIGTERM; fault inside the replicator stop/wait) handler re-entered close()infinite recursion → SIGKILL-only shutdown, spurious SIGSEGV, logs flooded (~975 recursion frames/job observed on OSMO pods) guard: re-entrant signal falls back to SIG_DFL
Signal racing the normal atexit close nested full second teardown of a half-closed app atexit arms the same guard
kill -ABRT graceful close → exit 0 same truthful-close path as SIGTERM
Real SIGSEGV, main thread Python handler can never run → process spins forever at 100% CPU, crash reporter clobbered (no minidump) SIGSEGV no longer intercepted → default action, minidumps restored
Real SIGSEGV, worker thread handler ran on the main thread → exit 0 for a crashed process same: default action, truthful signal death
Ctrl-C SimulationApp's handler exits 0 before user finally/KeyboardInterrupt code runs Python default handler restored: KeyboardInterrupt unwinds user code, nonzero exit

Not addressed here (tracked elsewhere): sys.exit(N)/SystemExit still exits 0 (gap inside the #6634 mechanism, documented at the detection site); the fast-shutdown disable on the signal path is an interim cost removed by isaac-sim/IsaacSim#717.

Implementation notes

  1. All exit-path logic lives in AppLauncher._SimulationAppLifecycle; the class docstring is the policy table, and each decision carries its rationale in place.
  2. The signal path disables Kit fast shutdown so close() returns, then re-raises with the default action (WORKAROUND(isaac-sim), removable once IsaacSim#717 lands). If the override stops taking effect, a [ISAACLAB] WARNING is printed instead of silently regressing.
  3. Docs: distributed camera training fails deterministically when the allocated GPUs span NUMA nodes and passes on a single-switch set; disabling NCCL cuMem host allocations rescues the failing shape in paired same-node experiments. Added to the multi-GPU NCCL troubleshooting section.

Testing. Five kitless unit tests (test_simulation_app_lifecycle.py: re-entrancy both directions, killed-by-signal contract, exit-code selection, drift fallback) plus two real-Kit integration tests (test_app_launcher_exit_status.py: SIGTERM → dies by SIGTERM with no recursion; unhandled exception → exits 1). All fail against the previous behavior — the SIGTERM test observes exit code 0 on develop — and pass with the fix. The integration tests also pass unchanged against an IsaacSim build patched with IsaacSim#717, confirming no lab change is needed when it lands.

Type of change

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

Checklist

  • I have read and understood the contribution guidelines
  • 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 added a changelog fragment under source/<pkg>/changelog.d/ for every touched package (do not edit CHANGELOG.rst or bump extension.toml — CI handles that)
  • I have added my name to the CONTRIBUTORS.md or my name already exists there

hujc7 added 2 commits July 19, 2026 23:19
The AppLauncher abort handler for SIGTERM/SIGABRT/SIGSEGV calls
SimulationApp.close() directly. When another signal is delivered while
close() is already running -- for example a repeated SIGTERM from a
distributed launcher tearing down workers, or a fault raised inside
close() itself -- the handler re-enters and calls close() again. The
nested calls recurse until the interpreter stack overflows, so the
process neither shuts down cleanly nor reports the original signal.

On multi-GPU training this surfaced as workers that ignored SIGTERM and
had to be escalated to SIGKILL, and as a spurious segmentation fault that
masked the real first failure behind the stack overflow.

Guard the handler with a re-entrancy flag: the first signal closes the
app once, and any signal that arrives while closing falls back to the
default action and re-raises, so the process terminates with the
original signal.

Add a kitless regression test that drives the handler with a close()
that re-signals, asserting close() runs exactly once and the re-entrant
signal falls back to SIG_DFL.
Multi-GPU training with RTX rendering enabled fails on some systems when
the participating GPUs span multiple NUMA nodes: one of the first
collectives times out (NCCL watchdog on an early BROADCAST/ALLREDUCE)
while the same training passes on a single-switch GPU set, and while
rendering-disabled training passes on any GPU set. Disabling NCCL's
cuMem allocator restores these runs.

Add the symptom signature and workaround to the existing NCCL
troubleshooting section of the multi-GPU documentation.
@hujc7
hujc7 requested a review from a team July 20, 2026 20:34
@github-actions github-actions Bot added bug Something isn't working documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team labels Jul 20, 2026
@greptile-apps

greptile-apps Bot commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a re-entrancy bug in AppLauncher._abort_signal_handle_callback where a repeated signal (e.g. a second SIGTERM from torchrun during distributed teardown) caused unbounded recursion into SimulationApp.close(), leading to stack overflows and masked failure signals. It also adds NCCL_CUMEM_ENABLE=0 to the multi-GPU troubleshooting docs for the NUMA-topology / RTX-rendering watchdog-timeout scenario.

  • Signal handler fix: An _abort_in_progress flag is set before close() is called; any re-entrant signal restores SIG_DFL and re-raises, terminating the process with the original signal rather than overflowing the stack.
  • Regression test: A kitless unit test using types.SimpleNamespace and monkeypatch verifies that close() is called exactly once and that the fallback path fires correctly for re-entrant signals.
  • Docs update: New paragraph in multi_gpu.rst describes the NCCL watchdog-timeout symptom signature and the NCCL_CUMEM_ENABLE=0 workaround, placed appropriately within the existing NCCL troubleshooting section.

Confidence Score: 5/5

Safe to merge. The re-entrancy guard is minimal, correct, and does not change the happy-path shutdown sequence.

The flag is set before close() is called so any re-entrant signal immediately takes the fallback path; the rename of the signal parameter to signum is both a style improvement and a correctness prerequisite for the new signal.signal() and signal.raise_signal() calls inside the handler. The regression test exercises the re-entrant path end-to-end without needing a live Kit instance. The docs change is accurately scoped to the observed symptom and workaround. No existing behaviour is altered on the first signal delivery.

No files require special attention.

Important Files Changed

Filename Overview
source/isaaclab/isaaclab/app/app_launcher.py Adds _abort_in_progress flag to prevent re-entrant signal handler recursion; renames the shadowed signal parameter to signum (required for the new signal.signal() / signal.raise_signal() calls to resolve to the module, not the parameter).
source/isaaclab/test/app/test_signal_handlers.py New kitless regression test: uses types.SimpleNamespace + monkeypatch to verify close() is called exactly once and that the re-entrant fallback (SIG_DFL + raise_signal) fires correctly.
docs/source/features/multi_gpu.rst Adds a new paragraph and NCCL_CUMEM_ENABLE=0 workaround for NCCL watchdog-timeout failures on NUMA-spanning multi-GPU hosts with RTX rendering enabled; well-placed within the existing troubleshooting section.
source/isaaclab/changelog.d/fix-abort-handler-reentrancy.rst Changelog fragment accurately describes the re-entrancy bug and the fix; follows the existing format.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant OS as OS / torchrun
    participant SH as _abort_signal_handle_callback
    participant Flag as _abort_in_progress
    participant App as SimulationApp.close()

    OS->>SH: SIGTERM (first delivery)
    SH->>Flag: check False
    SH->>Flag: set True
    SH->>App: close() [starts long teardown]
    Note over App: e.g. replicator orchestrator.stop() blocks...

    OS-->>SH: SIGTERM (re-entrant, during close)
    SH->>Flag: check True
    SH->>OS: signal.signal(SIGTERM, SIG_DFL)
    SH->>OS: signal.raise_signal(SIGTERM)
    Note over OS: process terminates with original signal

    App-->>SH: close() returns (if re-entrant signal did not kill yet)
    SH-->>OS: handler returns
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant OS as OS / torchrun
    participant SH as _abort_signal_handle_callback
    participant Flag as _abort_in_progress
    participant App as SimulationApp.close()

    OS->>SH: SIGTERM (first delivery)
    SH->>Flag: check False
    SH->>Flag: set True
    SH->>App: close() [starts long teardown]
    Note over App: e.g. replicator orchestrator.stop() blocks...

    OS-->>SH: SIGTERM (re-entrant, during close)
    SH->>Flag: check True
    SH->>OS: signal.signal(SIGTERM, SIG_DFL)
    SH->>OS: signal.raise_signal(SIGTERM)
    Note over OS: process terminates with original signal

    App-->>SH: close() returns (if re-entrant signal did not kill yet)
    SH-->>OS: handler returns
Loading

Reviews (1): Last reviewed commit: "Document NCCL_CUMEM_ENABLE=0 workaround ..." | Re-trigger Greptile

Extend the re-entrancy fix into a complete teardown-correctness change
based on a combined review with the atexit exit-code fix (isaac-sim#6634):

- Report killed-by-signal status: the handler previously ran a graceful
  close whose Kit fast-shutdown path terminated the process with exit
  code 0, so a SIGTERM-ed worker was recorded as successful and
  distributed launchers misattributed the failure. The handler now
  disables fast shutdown so close() performs the full teardown and
  returns, then re-raises the signal with the default action so the
  process exits with the conventional 128+signum status. The override
  is marked WORKAROUND(isaac-sim) for removal once SimulationApp can
  propagate a nonzero exit status through its fast-shutdown path.

- Arm the re-entrancy guard in the atexit close (extracted to the
  testable _close_app_at_exit method) so a signal arriving during a
  normal shutdown takes the guarded path instead of starting a nested
  teardown of a half-closed app.

- Stop intercepting SIGSEGV: a Python handler never runs for a
  synchronous main-thread segfault (the process spins on signal
  delivery), reports success for worker-thread segfaults, and replaces
  the carb crash reporter's handler, suppressing minidumps.

- Restore Python's default SIGINT handler over SimulationApp's, which
  exits 0 before user finally blocks or KeyboardInterrupt handlers can
  run. Marked WORKAROUND(isaac-sim) for removal once the upstream
  handler preserves exception semantics and a nonzero exit status.

- Remove the unregistered, dead _interrupt_signal_handle_callback.

Tests cover the single-close-plus-reraise contract, the re-entrant
signal path, and the atexit guard arming; all three fail against the
previous behavior.
@hujc7 hujc7 changed the title Fix abort-signal handler re-entrancy in AppLauncher and document mGPU NCCL workaround Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround Jul 20, 2026
hujc7 added 3 commits July 20, 2026 14:45
Launch a headless CPU AppLauncher in a child process, send SIGTERM once
the app is ready, and assert the process dies by SIGTERM instead of
reporting a successful exit, with no abort-handler recursion in stderr.

Verified against the previous behavior: without the handler fix the
child exits 0 (Kit fast shutdown swallows the termination status); with
the fix it reports killed-by-SIGTERM.
Paired experiments on the deterministic cross-NUMA reproduction show the
narrower knob is sufficient: the same GPU set that fails flag-free
passes with only cuMem host allocations disabled. Recommend it first,
with the full cuMem allocator disable as the fallback, so affected
systems keep the device-side allocator benefits.
Gather the startup announcements (CI marker, Kit version diagnostics)
and the entire exit-path policy (atexit close, signal handlers, exit
codes) into a nested AppLauncher._SimulationAppLifecycle class. The
pieces coordinate through one guard flag and exist for one reason --
report the process state truthfully to whatever supervises it -- so a
single class with the policy table as its docstring replaces logic
previously spread across __init__ and three private methods.

Absorb the atexit exit-code fix from PR isaac-sim#6634 (nblauch) into the
lifecycle class: the atexit close passes a nonzero exit code when an
unhandled exception is pending, so Kit fast shutdown does not replace
the failure status with 0. Includes that PR's integration test and a
kitless unit test for the exit-code selection. SystemExit is documented
as not yet detected.

Behavior is otherwise unchanged; all kitless unit tests and both
real-Kit integration tests (SIGTERM status, exception exit code) pass.
@hujc7
hujc7 marked this pull request as draft July 20, 2026 23:56
Merge the two single-test real-Kit integration files into
test_app_launcher_exit_status.py: both launch a headless AppLauncher in
a child process and assert on the exit outcome (killed by SIGTERM,
failed with an unhandled exception), sharing one trigger harness.

Rename the kitless unit file to test_simulation_app_lifecycle.py to
match the class it exercises; its previous name predated the exit-code
coverage.
The two WORKAROUND(isaac-sim) blocks were wrapped in blanket exception
suppression, so an upstream SimulationApp change could silently disable
them and quietly reintroduce the exit-status masking. Print a clear
warning when the fast-shutdown override does not take, and fall back to
a plain close() with a warning if close() stops accepting exit_code.

Reference the upstream fix (isaac-sim/IsaacSim#717) in the workaround
note; once it lands, both blocks can be deleted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working documentation Improvements or additions to documentation isaac-lab Related to Isaac Lab team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant