Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround#6636
Make AppLauncher teardown truthful and re-entrancy-safe; document mGPU NCCL workaround#6636hujc7 wants to merge 12 commits into
Conversation
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.
Greptile SummaryThis PR consolidates all
Confidence Score: 5/5Safe 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
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]
%%{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]
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.
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.
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.
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: |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
let's avoid mentioning --enable_cameras, it shouldn't be necessary anymore
| try: | ||
| self._app.close(exit_code=exit_code) | ||
| except TypeError: | ||
| print( |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
what happens if app.close() hits a different TypeError? could that ever become the case?
Summary
AppLauncherprocess-lifecycle handling into one nested_SimulationAppLifecycleclass: startup announcements (CI marker, Kit version diagnostics) plus the entire exit-path policy, with the policy table as the class docstring.NCCL_CUMEM_HOST_ENABLE=0first,NCCL_CUMEM_ENABLE=0as fallback).SimulationAppexit-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 passesclose(exit_code=128 + signum)— full teardown + truthful status on fixed builds, truthful status on older builds. The one remainingWORKAROUND(isaac-sim)is the SIGINT re-registration (upstream handler still exits 0 before user code unwinds); it fails loudly if drift disables it.close(exit_code=128 + signum)(full Kit teardown onisaacsim.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 TCPStoreBroken pipeerrors.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 unqualifiedclose()was reported as success; additionally, the abort-signal handler was unguarded against re-entrancy.sys.last_excdetected; absorbed from #6634;SystemExitdocumented as not yet covered)kill)close(exit_code=128 + signum): full teardown + truthful status onisaacsim.simulation_app>= 2.18.5; truthful status on older builds; dies by the signal ifclose()returnsclose()→ infinite recursion → SIGKILL-only shutdown, spurious SIGSEGV, logs flooded (~975 recursion frames/job observed on OSMO pods)SIG_DFLkill -ABRTfinally/KeyboardInterruptcode runsKeyboardInterruptunwinds user code, nonzero exitNot addressed here (tracked elsewhere):
sys.exit(N)/SystemExitstill exits 0 (gap inside the #6634 mechanism, documented at the detection site).Implementation notes
AppLauncher._SimulationAppLifecycle; the class docstring is the policy table, and each decision carries its rationale in place.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; ifclose()returns (fast shutdown disabled), the handler re-raises with the default action. ATypeErrorfallback warns loudly if a futureSimulationAppdrops the parameter.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)SimulationAppbuilds; on develop's original behavior the SIGTERM test observes exit code 0 (the bug).Type of change
Checklist
pre-commitchecks with./isaaclab.sh --formatsource/<pkg>/changelog.d/for every touched package (do not editCHANGELOG.rstor bumpextension.toml— CI handles that)CONTRIBUTORS.mdor my name already exists there