Skip to content

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

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

Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround#6636
hujc7 wants to merge 12 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 upstream SimulationApp exit-status fix (public reference: Preserve nonzero exit status through fast-shutdown close IsaacSim#717) has been merged (isaacsim.simulation_app >= 2.18.5): the signal handler now simply passes close(exit_code=128 + signum) — full teardown + truthful status on fixed builds, truthful status on older builds. The one remaining WORKAROUND(isaac-sim) is the SIGINT re-registration (upstream handler still exits 0 before user code unwinds); it fails loudly if drift disables it.
  • Fixes AppLauncher atexit handler masks uncaught exceptions as exit code 0 #6573 (absorbed atexit exit-code fix, originally Fix AppLauncher exception exit status #6634 by @nblauch).
  • Fixes [Bug Report] AppLauncher SIGTERM handler does not terminate Python workers #6530: the SIGTERM handler no longer returns to the interrupted execution path — the worker exits through close(exit_code=128 + signum) (full Kit teardown on isaacsim.simulation_app >= 2.18.5) or dies by the re-raised signal, so distributed workers terminate with a truthful status instead of surviving and spamming TCPStore Broken pipe errors.

Failure cases and how this PR addresses them

Root mechanism: Kit fast shutdown terminated the process with exit code 0 from inside SimulationApp.close(), so any death funneled through an unqualified close() was 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 close(exit_code=128 + signum): full teardown + truthful status on isaacsim.simulation_app >= 2.18.5; truthful status on older builds; dies by the signal if close() returns
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).

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 handler passes the killed-by-signal status through close(exit_code=128 + signum). With the merged upstream fix (isaacsim.simulation_app >= 2.18.5) the app performs its full teardown and exits with that status; on older builds the status is preserved without the teardown; if close() returns (fast shutdown disabled), the handler re-raises with the default action. A TypeError fallback warns loudly if a future SimulationApp drops the parameter.
  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. Six kitless unit tests (test_simulation_app_lifecycle.py: killed-by-signal status, re-entrancy both directions, exit-code selection, drift fallbacks on both paths) plus two real-Kit integration tests (test_app_launcher_exit_status.py: SIGTERM → truthful termination status with no handler recursion; unhandled exception → exits 1). The integration tests pass against both the pre-fix and the fixed (>= 2.18.5) SimulationApp builds; on develop's original behavior the SIGTERM test observes exit code 0 (the bug).

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 consolidates all AppLauncher process-lifecycle handling into a new _SimulationAppLifecycle inner class, fixing several exit paths where Kit fast shutdown masked failures as exit code 0 and where repeated signals triggered infinite recursion. It also documents the NCCL_CUMEM_HOST_ENABLE=0 workaround for distributed RTX training failures on NUMA-spanning GPU configurations.

  • Re-entrancy guard: _closing flag prevents a second close() call when a signal arrives while the app is already tearing down; both the atexit hook and the signal handler arm the same flag.
  • Truthful exit status: _close_at_exit passes exit_code=1 when sys.last_exc is set; _on_abort_signal disables Kit fast shutdown so close() returns, then re-raises with SIG_DFL to produce the conventional 128+signum status.
  • SIGSEGV removed: the Python-level handler is intentionally dropped so the carb crash reporter can produce minidumps and the main-thread spin-at-signal-delivery is avoided.

Confidence Score: 5/5

Safe to merge; the exit-path logic is well-reasoned, each decision is explicitly documented, and the two workarounds are clearly annotated as temporary with a deletion path tied to an upstream issue.

All changes are in the teardown/signal-handling path, exercised by the new unit and integration tests. The implementation carefully handles the re-entrant signal case, the fast-shutdown workaround, and exit-code preservation. The only findings are minor edge cases that do not affect correctness under normal conditions.

No files require special attention; the core logic in app_launcher.py is well-tested by the two new test files.

Important Files Changed

Filename Overview
source/isaaclab/isaaclab/app/app_launcher.py Core change: replaces ad-hoc signal/atexit setup with _SimulationAppLifecycle inner class; re-entrancy guard, truthful exit codes, and fast-shutdown workaround are well-designed; one minor API-boundary concern in the TypeError catch
source/isaaclab/test/app/test_simulation_app_lifecycle.py Five kitless unit tests cover re-entrancy both directions, atexit guard, exit-code selection, and drift fallback — all targeting the specific failure modes described in the PR; test logic is sound
source/isaaclab/test/app/test_app_launcher_exit_status.py Two integration tests launch a real Kit process and assert on exit status; the SIGTERM test startup-ready loop can hang indefinitely if the child never emits stdout
docs/source/features/multi_gpu.rst Adds NCCL cuMem workaround documentation for distributed RTX training failures on NUMA-spanning GPU allocations; prose is clear and accurate
source/isaaclab/changelog.d/fix-abort-handler-reentrancy.rst New changelog fragment accurately describes all exit-path fixes and the intentional SIGSEGV removal; no issues

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[AppLauncher.__init__] --> B[_SimulationAppLifecycle.__init__]
    B --> C[announce_startup]
    C --> D[install_exit_handlers]
    D --> E{Process ends how?}
    E -->|Normal exit| F[_close_at_exit]
    F --> G{sys.last_exc set?}
    G -->|Yes| H[app.close exit_code=1]
    G -->|No| I[app.close exit_code=0]
    E -->|SIGTERM or SIGABRT| J[_on_abort_signal]
    J --> K{_closing True?}
    K -->|Yes re-entrant| L[SIG_DFL + raise_signal]
    K -->|No| M[_closing=True disable fast shutdown]
    M --> N[app.close]
    N --> O[SIG_DFL + raise_signal 128+signum]
    E -->|SIGINT| P[default_int_handler KeyboardInterrupt raised]
    E -->|SIGSEGV| Q[Not intercepted carb minidumps]
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"}}}%%
flowchart TD
    A[AppLauncher.__init__] --> B[_SimulationAppLifecycle.__init__]
    B --> C[announce_startup]
    C --> D[install_exit_handlers]
    D --> E{Process ends how?}
    E -->|Normal exit| F[_close_at_exit]
    F --> G{sys.last_exc set?}
    G -->|Yes| H[app.close exit_code=1]
    G -->|No| I[app.close exit_code=0]
    E -->|SIGTERM or SIGABRT| J[_on_abort_signal]
    J --> K{_closing True?}
    K -->|Yes re-entrant| L[SIG_DFL + raise_signal]
    K -->|No| M[_closing=True disable fast shutdown]
    M --> N[app.close]
    N --> O[SIG_DFL + raise_signal 128+signum]
    E -->|SIGINT| P[default_int_handler KeyboardInterrupt raised]
    E -->|SIGSEGV| Q[Not intercepted carb minidumps]
Loading

Reviews (2): Last reviewed commit: "Fail loudly if the SimulationApp workaro..." | 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.
@hujc7
hujc7 marked this pull request as ready for review July 21, 2026 20:15
hujc7 added 3 commits July 21, 2026 13:51
The upstream SimulationApp fix is merged (isaacsim.simulation_app
2.18.5): close(exit_code) now performs its full teardown and exits with
the given status under fast shutdown. Replace the interim fast-shutdown
disable in the abort handler with close(exit_code=128 + signum): on
2.18.5+ the app tears down fully and exits truthfully; on older builds
the status is still truthful without the teardown; if close() returns
(fast shutdown disabled), the handler re-raises the signal with the
default action as before.

The remaining WORKAROUND(isaac-sim) is the SIGINT re-registration,
which the merged fix does not cover.

The SIGTERM integration test accepts both truthful outcomes (exit
status 128+SIGTERM or death by SIGTERM) and passes against both the
pre-fix and the fixed SimulationApp builds.
The upstream exit-status fix (isaacsim.simulation_app 2.18.5)
deliberately excluded the SIGINT handler because fixing it changes
user-visible Ctrl-C semantics for every SimulationApp consumer; state
that rationale at the workaround site.
Compress the class docstring and per-site comments to essentials, fold
the duplicated exit_code TypeError fallback into a _close_app helper,
and describe the SIGINT registration as behavior not handled on the sim
side (changing it there affects Ctrl-C semantics for every consumer)
rather than as a workaround.
try:
# wait for the app to finish starting up
deadline = time.time() + 300
for line in proc.stdout:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[AI review] [P2] Make the startup timeout interrupt the pipe read

The loop over proc.stdout blocks until a line or EOF arrives, so the 300-second deadline is never evaluated if Kit hangs silently during startup. In that failure mode this test can stall the entire shard until the GitHub Actions job timeout.

Please use a bounded readiness mechanism such as select/selectors, a reader thread with a timeout, or equivalent, and preserve the child output in the timeout failure.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in ddc38bd: readiness now waits on an Event fed by a daemon reader thread, so a silent startup hang or an early child exit fails within the 300 s deadline instead of stalling the shard, and the failure report preserves the child's stdout and stderr.

The readiness loop iterated the child's stdout with a blocking
readline, so its 300-second deadline was only checked between lines: a
child hanging silently during startup stalled the test and its CI shard
until the job timeout. Wait on an event fed by a daemon reader thread
instead, fail fast when the child exits before printing the ready
marker, and preserve the collected child output in the failure report.
export NCCL_ALGO=Ring

On some multi-GPU systems, distributed training with RTX rendering enabled (for example,
camera-based tasks launched with ``--enable_cameras``) fails when the participating GPUs span

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.

let's avoid mentioning --enable_cameras, it shouldn't be necessary anymore

try:
self._app.close(exit_code=exit_code)
except TypeError:
print(

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.

should we use logging instead of print here?

def _close_app(self, exit_code):
"""Close the app with ``exit_code``, warning loudly if the parameter is unsupported."""
try:
self._app.close(exit_code=exit_code)

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.

what happens if app.close() hits a different TypeError? could that ever become the case?

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.

3 participants