diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index bf9e48ba0..0077b2ecd 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -10,8 +10,10 @@ from __future__ import annotations +import argparse import asyncio import atexit +import contextlib import logging import os import signal @@ -166,6 +168,83 @@ def __init__( self._start_wss_proxy(wss_log_path) logger.info("CloudXR WSS proxy started (log=%s)", wss_log_path) + # ------------------------------------------------------------------ + # CLI helpers for embedding applications and examples + # ------------------------------------------------------------------ + + @staticmethod + def add_cloudxr_install_dir_argument(parser: argparse.ArgumentParser) -> None: + """Register ``--cloudxr-install-dir`` on ``parser`` (default ``~/.cloudxr``).""" + parser.add_argument( + "--cloudxr-install-dir", + type=str, + default=os.path.expanduser("~/.cloudxr"), + metavar="PATH", + help="CloudXR install directory (default: ~/.cloudxr)", + ) + + @staticmethod + def add_launch_cloudxr_runtime_argument(parser: argparse.ArgumentParser) -> None: + """Register ``--launch-cloudxr-runtime`` on ``parser``. + + Uses :class:`argparse.BooleanOptionalAction`, so callers may pass + ``--no-launch-cloudxr-runtime`` when the runtime is already running + (for example after sourcing ``~/.cloudxr/run/cloudxr.env``). + """ + parser.add_argument( + "--launch-cloudxr-runtime", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Launch the CloudXR runtime and WSS proxy in-process before running " + "(default: true). Pass --no-launch-cloudxr-runtime when the runtime is " + "already running (e.g. after sourcing ~/.cloudxr/run/cloudxr.env)." + ), + ) + + @staticmethod + def add_launcher_arguments(parser: argparse.ArgumentParser) -> None: + """Register ``--cloudxr-install-dir`` and ``--launch-cloudxr-runtime``.""" + CloudXRLauncher.add_cloudxr_install_dir_argument(parser) + CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) + + @staticmethod + def _resolve_install_dir( + args: argparse.Namespace, + install_dir: str | None = None, + ) -> str: + """Return ``install_dir`` or ``args.cloudxr_install_dir`` when registered.""" + if install_dir is not None: + return install_dir + return getattr(args, "cloudxr_install_dir", "~/.cloudxr") + + @staticmethod + def launch_context( + args: argparse.Namespace, + *, + install_dir: str | None = None, + env_config: str | Path | None = None, + accept_eula: bool = False, + setup_oob: bool = False, + usb_local: bool = False, + host_client: bool = False, + ) -> contextlib.AbstractContextManager[CloudXRLauncher | None]: + """Start :class:`CloudXRLauncher` when ``args.launch_cloudxr_runtime`` is true. + + Returns :func:`contextlib.nullcontext` when ``args.launch_cloudxr_runtime`` is + false so callers can always use ``with CloudXRLauncher.launch_context(args):``. + """ + if not args.launch_cloudxr_runtime: + return contextlib.nullcontext(None) + return CloudXRLauncher( + install_dir=CloudXRLauncher._resolve_install_dir(args, install_dir), + env_config=env_config, + accept_eula=accept_eula, + setup_oob=setup_oob, + usb_local=usb_local, + host_client=host_client, + ) + # ------------------------------------------------------------------ # Context manager # ------------------------------------------------------------------ @@ -339,15 +418,19 @@ def _is_runtime_alive(self) -> bool: def _terminate_runtime(self) -> None: """Terminate the runtime subprocess and all its children. - Because the subprocess is launched with ``start_new_session=True`` - it is the leader of its own process group. Sending the signal to - the negative PID kills the entire group (including Monado and any - other children), preventing stale processes from lingering. + On POSIX, the subprocess is launched with ``start_new_session=True`` + so it leads its own process group; ``killpg`` tears down Monado and + other children. Windows is not supported (see + :meth:`_terminate_runtime_windows`). """ proc = self._runtime_proc if proc is None or proc.poll() is not None: return + if sys.platform == "win32": + self._terminate_runtime_windows(proc) + return + try: pgid = os.getpgid(proc.pid) except ProcessLookupError: @@ -375,6 +458,13 @@ def _terminate_runtime(self) -> None: if proc.poll() is None: raise RuntimeError("Failed to terminate or kill runtime process group") + @staticmethod + def _terminate_runtime_windows(_proc: subprocess.Popen) -> None: + """Windows runtime termination is not supported.""" + raise RuntimeError( + "CloudXR runtime process termination is not supported on Windows" + ) + # ------------------------------------------------------------------ # WSS proxy (background thread with its own event loop) # ------------------------------------------------------------------ diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index c7ffbcbee..eb7d3e2b5 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -3,6 +3,7 @@ """Tests for isaacteleop.cloudxr.launcher — CloudXRLauncher lifecycle.""" +import argparse import os import signal import subprocess @@ -20,6 +21,11 @@ reason="Process-group APIs (os.getpgid/os.killpg) are POSIX-only", ) +_windows_skip = pytest.mark.skipif( + sys.platform == "win32", + reason="CloudXR runtime process termination is not supported on Windows", +) + # ============================================================================ # Helpers @@ -47,8 +53,11 @@ def ensure_logs_dir(self) -> Path: def _make_mock_popen(pid: int = 12345, poll_returns: list | None = None) -> MagicMock: """Create a mock subprocess.Popen with configurable poll() behaviour.""" - proc = MagicMock(spec=subprocess.Popen) + proc = MagicMock() proc.pid = pid + proc.terminate = MagicMock() + proc.kill = MagicMock() + proc.wait = MagicMock() if poll_returns is not None: seq = list(poll_returns) @@ -314,5 +323,71 @@ def test_handles_missing_fuser(self, tmp_path): assert not os.path.exists(cloudxr_pid) +class TestLaunchArgumentHelpers: + """Tests for CloudXRLauncher CLI helper methods.""" + + def test_add_cloudxr_install_dir_argument_default(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_cloudxr_install_dir_argument(parser) + args = parser.parse_args([]) + assert args.cloudxr_install_dir == os.path.expanduser("~/.cloudxr") + + def test_add_cloudxr_install_dir_argument_custom(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_cloudxr_install_dir_argument(parser) + args = parser.parse_args(["--cloudxr-install-dir", "/opt/cloudxr"]) + assert args.cloudxr_install_dir == "/opt/cloudxr" + + def test_add_launcher_arguments_registers_both(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_launcher_arguments(parser) + args = parser.parse_args( + ["--cloudxr-install-dir", "/opt/cloudxr", "--no-launch-cloudxr-runtime"] + ) + assert args.cloudxr_install_dir == "/opt/cloudxr" + assert args.launch_cloudxr_runtime is False + + def test_add_launch_cloudxr_runtime_argument_defaults_true(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) + args = parser.parse_args([]) + assert args.launch_cloudxr_runtime is True + + def test_add_launch_cloudxr_runtime_argument_no_launch(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) + args = parser.parse_args(["--no-launch-cloudxr-runtime"]) + assert args.launch_cloudxr_runtime is False + + def test_launch_context_skips_when_disabled(self) -> None: + args = argparse.Namespace(launch_cloudxr_runtime=False) + with CloudXRLauncher.launch_context(args) as launcher: + assert launcher is None + + @_windows_skip + def test_launch_context_starts_when_enabled(self, tmp_path) -> None: + args = argparse.Namespace( + launch_cloudxr_runtime=True, + cloudxr_install_dir="/opt/cloudxr", + ) + with mock_launcher_deps(tmp_path) as mocks: + with CloudXRLauncher.launch_context(args) as launcher: + assert launcher is not None + assert launcher._runtime_proc is mocks["proc"] + assert launcher._install_dir == "/opt/cloudxr" + mocks["proc"].poll.return_value = 0 + + @_posix_only + def test_stop_on_windows_raises_unsupported(self, tmp_path) -> None: + """Simulated win32 platform raises instead of calling POSIX APIs.""" + with mock_launcher_deps(tmp_path, ready=True) as mocks: + launcher = CloudXRLauncher() + mocks["proc"].poll.return_value = None + + with patch("isaacteleop.cloudxr.launcher.sys.platform", "win32"): + with pytest.raises(RuntimeError, match="not supported on Windows"): + launcher.stop() + + if __name__ == "__main__": pytest.main([__file__, "-v"])