diff --git a/examples/camera_viz/camera_viz.sh b/examples/camera_viz/camera_viz.sh index 9a9d80402..5700f9670 100755 --- a/examples/camera_viz/camera_viz.sh +++ b/examples/camera_viz/camera_viz.sh @@ -374,7 +374,7 @@ cmd_service_restart() { # ────────────────────────────────────────────────────────────────────── show_help() { - cat < None: The process handle is retained so callers can retry or inspect the still-running process. """ + self._restore_signal_handlers() self._stop_wss_proxy() if self._runtime_proc is not None: @@ -236,6 +246,51 @@ def wss_log_path(self) -> Path | None: # Private helpers # ------------------------------------------------------------------ + def _install_signal_handlers(self) -> None: + """Tear the runtime down on SIGTERM/SIGINT. + + Signals don't trigger ``atexit``, and the runtime runs in its own + session, so a signalled shutdown would otherwise orphan it. Each + handler runs :meth:`stop` then chains to the previously-installed + disposition, so embedding apps keep their own shutdown behaviour. + No-op off the main thread (``signal.signal`` only works there). + """ + if threading.current_thread() is not threading.main_thread(): + return + + def _make_handler(prev): + def _handler(signum, frame): + try: + self.stop() + finally: + if callable(prev): + prev(signum, frame) + else: + # SIG_DFL / SIG_IGN: restore it and re-raise so the + # default (terminate) or ignore behaviour applies. + signal.signal(signum, prev) + if prev == signal.SIG_DFL: + os.kill(os.getpid(), signum) + + return _handler + + for sig in (signal.SIGTERM, signal.SIGINT): + try: + prev = signal.getsignal(sig) + signal.signal(sig, _make_handler(prev)) + except (ValueError, OSError): + continue + self._prev_signal_handlers[sig] = prev + + def _restore_signal_handlers(self) -> None: + """Restore signal handlers saved by :meth:`_install_signal_handlers`.""" + while self._prev_signal_handlers: + sig, prev = self._prev_signal_handlers.popitem() + try: + signal.signal(sig, prev) + except (ValueError, OSError): + pass + @staticmethod def _cleanup_stale_runtime(env_cfg: EnvConfig) -> None: """Remove stale sentinel files from a previous runtime that wasn't cleaned up. diff --git a/src/core/cloudxr/python/runtime.py b/src/core/cloudxr/python/runtime.py index bb050ea29..a085ec252 100644 --- a/src/core/cloudxr/python/runtime.py +++ b/src/core/cloudxr/python/runtime.py @@ -266,16 +266,23 @@ def stop(sig: int, frame: object) -> None: state["service_created"] = True lib.nv_cxr_service_start(svc) - # Run the blocking join() in a worker thread so the main thread stays in Python - # and can run the signal handler. Otherwise Ctrl+C is not processed while we're - # inside the native nv_cxr_service_join() call. - def join_then_destroy() -> None: + join_on_main = os.environ.get("NV_CXR_RUNTIME_JOIN_MAIN_THREAD", "").strip().lower() + if join_on_main in ("1", "true", "yes", "on"): + # Opt-in: join on the main thread to avoid a "Couldn't create autoTSSkey + # mapping" abort seen on some platforms. lib.nv_cxr_service_join(svc) lib.nv_cxr_service_destroy(svc) - - worker = threading.Thread(target=join_then_destroy, daemon=False) - worker.start() - worker.join() + else: + # Run the blocking join() in a worker thread so the main thread stays in Python + # and can run the signal handler. Otherwise Ctrl+C is not processed while we're + # inside the native nv_cxr_service_join() call. + def join_then_destroy() -> None: + lib.nv_cxr_service_join(svc) + lib.nv_cxr_service_destroy(svc) + + worker = threading.Thread(target=join_then_destroy, daemon=False) + worker.start() + worker.join() if state["interrupted"]: raise KeyboardInterrupt()