Skip to content

Preserve nonzero exit status through fast-shutdown close - #717

Closed
hujc7 wants to merge 1 commit into
isaac-sim:mainfrom
hujc7:jichuanh/preserve-close-exit-status
Closed

Preserve nonzero exit status through fast-shutdown close#717
hujc7 wants to merge 1 commit into
isaac-sim:mainfrom
hujc7:jichuanh/preserve-close-exit-status

Conversation

@hujc7

@hujc7 hujc7 commented Jul 21, 2026

Copy link
Copy Markdown

Description

SimulationApp.close(exit_code) currently behaves differently depending on the exit code: a nonzero code exits immediately via os._exit before any cleanup runs, while the graceful path always terminates the process with status 0 from inside app.shutdown() under fast shutdown. Callers therefore have to choose between cleanup and a truthful exit status. In practice, a worker that closes gracefully after a failure or a termination signal is reported as exit 0, which distributed launchers (torchrun) and CI record as a successful run — the surviving ranks then block until the NCCL watchdog fires, and the root cause is misattributed. (Downstream context: isaac-sim/IsaacLab#6636 currently works around this by disabling fast shutdown at signal time.)

This change makes exit_code orthogonal to teardown:

  • every close() path performs its normal cleanup regardless of the exit code;
  • a nonzero status is applied with os._exit(exit_code) exactly at the point where fast shutdown would otherwise terminate the process with 0 (before _arm_shutdown_watchdog(), since that path never reaches app.shutdown());
  • _flush_stdio() additionally flushes the C stdio streams (fflush(NULL) via ctypes): native output such as IApp.print_and_log is buffered on the C/C++ side, which os._exit abandons, so the tail of the shutdown log — including the Simulation App Shutting Down marker — was previously lost on every _exit path.

Verification

On a Linux source build (6.0.0 GA base, change rebased onto main with the shutdown-watchdog resolution described above), with a real headless app:

  • SimulationApp({"headless": True}).close(exit_code=5) → process exits 5, full cleanup runs, and Simulation App Shutting Down is present in the output (previously: either exit 5 with no cleanup, or cleanup with exit 0).
  • close() with the default exit code → unchanged: exits 0 with full cleanup.
  • Isaac Lab's AppLauncher exit-status integration tests (SIGTERM status, unhandled-exception status) pass unchanged against the patched build.

SimulationApp.close(exit_code) previously behaved differently by exit
code: a nonzero code exited immediately before any cleanup, while the
graceful path always terminated with status 0 from inside
app.shutdown() under fast shutdown. Callers therefore had to choose
between cleanup and a truthful exit status -- a SIGTERM-ed or failed
process that closed gracefully was reported as successful, which
distributed launchers and CI record as a passing run.

Make exit_code orthogonal to teardown: every close() path performs its
normal cleanup, and a nonzero status is applied exactly where fast
shutdown would otherwise terminate the process with 0.

Also flush the C stdio streams in _flush_stdio(): native output such as
IApp.print_and_log goes through the C/C++ buffers, which os._exit
abandons, so the tail of the shutdown log (including the "Simulation
App Shutting Down" marker) was lost on every _exit path.

Validated with a real headless app: close(exit_code=5) exits 5 with the
full cleanup log present; close() still exits 0; IsaacLab's AppLauncher
exit-status integration tests pass unchanged against this build.
hujc7 added a commit to hujc7/IsaacLab that referenced this pull request Jul 21, 2026
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 closed this Jul 21, 2026
hujc7 added a commit to isaac-sim/IsaacLab that referenced this pull request Jul 28, 2026
…U NCCL workaround (#6636)

# 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 #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:
isaac-sim/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 #6573 (absorbed
atexit exit-code fix, originally
#6634 by @nblauch).
- Fixes #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

- [x] I have read and understood the [contribution
guidelines](https://isaac-sim.github.io/IsaacLab/main/source/refs/contributing.html)
- [x] I have run the [`pre-commit` checks](https://pre-commit.com/) with
`./isaaclab.sh --format`
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] 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)
- [x] I have added my name to the `CONTRIBUTORS.md` or my name already
exists there
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant