Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 94 additions & 4 deletions src/core/cloudxr/python/launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@

from __future__ import annotations

import argparse
import asyncio
import atexit
import contextlib
import logging
import os
import signal
Expand Down Expand Up @@ -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)."

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.

since we are here, maybe add a arg for --cloudxr-install-dir as well (default to ~/.cloudxr)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Hmm, is it actually install dir? We don't install anything there right? It's more like run dir right?

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.

right... the naming is probably not perfect. I think we can update it.

),
)

@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
# ------------------------------------------------------------------
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
# ------------------------------------------------------------------
Expand Down
77 changes: 76 additions & 1 deletion src/core/cloudxr_tests/python/test_launcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

"""Tests for isaacteleop.cloudxr.launcher — CloudXRLauncher lifecycle."""

import argparse
import os
import signal
import subprocess
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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"])
Loading