From 48a045686cb1cbbc7ec1e7400bbaf146e4392875 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Mon, 13 Jul 2026 22:12:04 -0400 Subject: [PATCH 01/25] ci: Exercise Quick Start workflow on GPU runner --- .github/workflows/build-ubuntu.yml | 46 +++++ scripts/run_quick_start_e2e.sh | 271 +++++++++++++++++++++++++++++ 2 files changed, 317 insertions(+) create mode 100755 scripts/run_quick_start_e2e.sh diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 2aa6b6e57..c33866a07 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -526,6 +526,51 @@ jobs: # Run tests with CloudXR runtime ./scripts/run_tests_with_cloudxr.sh --python-version ${{ matrix.python_version }} + test-quick-start-e2e: + # Runs the documented Quick Start workflow against the candidate wheel on a + # GPU runner: install package, launch CloudXR, source the generated env, run + # the documented gripper retargeting example, and upload the logs/summary. + # The desktop browser/IWER path remains the documented headset-free client + # path; this baseline validates the runnable guide steps without scraping + # the published documentation site. + runs-on: [self-hosted, linux, gpu, x64] + needs: build-ubuntu + timeout-minutes: 30 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Download isaacteleop wheel (x64, py3.11) + uses: actions/download-artifact@v7 + with: + name: isaacteleop-wheels-x64-py3.11 + path: quick-start-wheels + + - name: Run Quick Start E2E workflow + env: + CI: true + QUICK_START_E2E_REQUIRE_WHEEL: "1" + QUICK_START_E2E_WHEEL_DIR: quick-start-wheels + QUICK_START_E2E_ARTIFACT_DIR: ${{ runner.temp }}/isaacteleop-quick-start-e2e + run: ./scripts/run_quick_start_e2e.sh + + - name: Upload Quick Start E2E artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v6 + with: + name: quick-start-e2e-x64-py3.11 + path: ${{ runner.temp }}/isaacteleop-quick-start-e2e/ + if-no-files-found: warn + retention-days: 7 + test-teleop-ros2: runs-on: [self-hosted, linux, gpu, "${{ matrix.arch }}"] needs: build-ubuntu @@ -831,6 +876,7 @@ jobs: - test-viz-gpu - test-viz-sanitizers - test-cloudxr + - test-quick-start-e2e - test-teleop-ros2 if: ${{ always() }} diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh new file mode 100755 index 000000000..344c8acfe --- /dev/null +++ b/scripts/run_quick_start_e2e.sh @@ -0,0 +1,271 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Exercise the runnable workflow from docs/source/getting_started/quick_start.rst +# on a clean runner environment. CI uses the wheel produced by the current run; +# local developers can omit QUICK_START_E2E_WHEEL_DIR to install from PyPI as the +# guide describes. + +set -euo pipefail + +ROOT_DIR=$(git rev-parse --show-toplevel) +GUIDE_PATH="docs/source/getting_started/quick_start.rst" +PYTHON_BIN="${QUICK_START_E2E_PYTHON:-python3}" +ARTIFACT_DIR="${QUICK_START_E2E_ARTIFACT_DIR:-${RUNNER_TEMP:-/tmp}/isaacteleop-quick-start-e2e}" +VENV_DIR="${QUICK_START_E2E_VENV_DIR:-${ARTIFACT_DIR}/venv}" +CLOUDXR_INSTALL_DIR="${QUICK_START_E2E_CLOUDXR_INSTALL_DIR:-${ARTIFACT_DIR}/cloudxr}" +WHEEL_DIR="${QUICK_START_E2E_WHEEL_DIR:-${ROOT_DIR}/install/wheels}" +PIP_SPEC="${QUICK_START_E2E_PIP_SPEC:-isaacteleop[cloudxr,retargeters]~=1.0.0}" +PIP_EXTRA_INDEX_URL="${QUICK_START_E2E_PIP_EXTRA_INDEX_URL:-https://pypi.nvidia.com}" +CLOUDXR_READY_TIMEOUT_SEC="${QUICK_START_E2E_CLOUDXR_READY_TIMEOUT_SEC:-180}" +EXAMPLE_TIMEOUT_SEC="${QUICK_START_E2E_EXAMPLE_TIMEOUT_SEC:-75}" +PROXY_PORT="${QUICK_START_E2E_WSS_PROXY_PORT:-48322}" + +CLOUDXR_LOG="${ARTIFACT_DIR}/cloudxr-server.log" +EXAMPLE_LOG="${ARTIFACT_DIR}/gripper-retargeting-example.log" +SUMMARY_JSON="${ARTIFACT_DIR}/quick-start-e2e-summary.json" + +cloudxr_pid="" + +log() { + printf '[quick-start-e2e] %s\n' "$*" +} + +fail() { + log "ERROR: $*" + write_summary "failed" "$*" + exit 1 +} + +cleanup() { + if [[ -n "${cloudxr_pid}" ]] && kill -0 "${cloudxr_pid}" 2>/dev/null; then + log "Stopping CloudXR server (pid=${cloudxr_pid})" + kill "${cloudxr_pid}" 2>/dev/null || true + wait "${cloudxr_pid}" 2>/dev/null || true + fi +} +trap cleanup EXIT + +json_escape() { + python3 - "$1" <<'PY' +import json +import sys + +print(json.dumps(sys.argv[1])) +PY +} + +write_summary() { + local status="$1" + local message="$2" + local gripper_lines="0" + if [[ -f "${EXAMPLE_LOG}" ]]; then + gripper_lines=$(grep -Ec 'Right:[[:space:]]*-?[0-9]+([.][0-9]+)?' "${EXAMPLE_LOG}" || true) + fi + + mkdir -p "${ARTIFACT_DIR}" + cat > "${SUMMARY_JSON}" </dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +wait_for_log_line() { + local pattern="$1" + local timeout="$2" + local deadline=$((SECONDS + timeout)) + + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if [[ -f "${CLOUDXR_LOG}" ]] && grep -qE "${pattern}" "${CLOUDXR_LOG}"; then + return 0 + fi + if [[ -n "${cloudxr_pid}" ]] && ! kill -0 "${cloudxr_pid}" 2>/dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +assert_clean_log() { + local label="$1" + local file="$2" + if grep -Eiq 'Traceback|RuntimeError|Segmentation fault|Aborted|failed to start|exited unexpectedly' "${file}"; then + log "===== ${label} =====" + cat "${file}" || true + fail "${label} contains a fatal error" + fi +} + +resolve_wheel() { + shopt -s nullglob + local wheels=("${WHEEL_DIR}"/isaacteleop-*.whl) + shopt -u nullglob + + if (( ${#wheels[@]} == 0 )); then + return 1 + fi + if (( ${#wheels[@]} > 1 )); then + printf 'Expected one wheel in %s, found %d\n' "${WHEEL_DIR}" "${#wheels[@]}" >&2 + return 2 + fi + + printf '%s\n' "${wheels[0]}" +} + +install_package() { + local venv_python="${VENV_DIR}/bin/python" + local wheel="" + local resolve_rc=0 + + "${PYTHON_BIN}" -m venv "${VENV_DIR}" + "${venv_python}" -m pip install --upgrade pip + + wheel=$(resolve_wheel) || resolve_rc=$? + if (( resolve_rc == 0 )); then + local wheel_name + local wheel_version + wheel_name=$(basename "${wheel}") + wheel_version=$(sed -E 's/^isaacteleop-([^-]+)-.*/\1/' <<< "${wheel_name}" | tr '_' '-') + log "Step 2: installing candidate wheel ${wheel_name} with guide extras" + "${venv_python}" -m pip install --no-cache-dir "${wheel}" + "${venv_python}" -m pip install \ + --no-cache-dir \ + --extra-index-url "${PIP_EXTRA_INDEX_URL}" \ + "isaacteleop[cloudxr,retargeters]==${wheel_version}" + elif (( resolve_rc == 1 )); then + if [[ "${QUICK_START_E2E_REQUIRE_WHEEL:-0}" == "1" ]]; then + fail "no isaacteleop wheel found in ${WHEEL_DIR}" + fi + log "Step 2: installing from PyPI spec: ${PIP_SPEC}" + "${venv_python}" -m pip install \ + --no-cache-dir \ + --extra-index-url "${PIP_EXTRA_INDEX_URL}" \ + "${PIP_SPEC}" + else + fail "could not resolve a single isaacteleop wheel from ${WHEEL_DIR}" + fi +} + +check_wss_proxy_port() { + "${VENV_DIR}/bin/python" - "${PROXY_PORT}" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +with socket.create_connection(("127.0.0.1", port), timeout=5.0): + pass +PY +} + +run_example() { + local env_file="$1" + + log "Step 6: sourcing ${env_file}" + # shellcheck disable=SC1090 + source "${env_file}" + + log "Step 7: running examples/teleop/python/gripper_retargeting_example_simple.py" + local example_rc=0 + if command -v timeout >/dev/null 2>&1; then + timeout "${EXAMPLE_TIMEOUT_SEC}" \ + "${VENV_DIR}/bin/python" "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" \ + > "${EXAMPLE_LOG}" 2>&1 || example_rc=$? + else + "${VENV_DIR}/bin/python" "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" \ + > "${EXAMPLE_LOG}" 2>&1 || example_rc=$? + fi + + if (( example_rc != 0 )); then + log "===== gripper retargeting example log =====" + cat "${EXAMPLE_LOG}" || true + fail "gripper example exited with status ${example_rc}" + fi + + assert_clean_log "gripper retargeting example log" "${EXAMPLE_LOG}" + grep -q 'Gripper Retargeting' "${EXAMPLE_LOG}" || fail "gripper example did not print its startup banner" + + local gripper_lines + gripper_lines=$(grep -Ec 'Right:[[:space:]]*-?[0-9]+([.][0-9]+)?' "${EXAMPLE_LOG}" || true) + if (( gripper_lines < 2 )); then + log "===== gripper retargeting example log =====" + cat "${EXAMPLE_LOG}" || true + fail "expected at least 2 gripper output lines, found ${gripper_lines}" + fi +} + +main() { + rm -rf "${ARTIFACT_DIR}" + mkdir -p "${ARTIFACT_DIR}" + + log "Step 1: using checked-out repository at ${ROOT_DIR}" + test -f "${ROOT_DIR}/${GUIDE_PATH}" || fail "Quick Start guide not found at ${GUIDE_PATH}" + test -f "${ROOT_DIR}/examples/teleop/python/gripper_retargeting_example_simple.py" || \ + fail "documented gripper example is missing" + + install_package + + log "Step 3: starting CloudXR server with --accept-eula" + "${VENV_DIR}/bin/python" -m isaacteleop.cloudxr \ + --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ + --accept-eula \ + > "${CLOUDXR_LOG}" 2>&1 & + cloudxr_pid=$! + + local env_file="${CLOUDXR_INSTALL_DIR}/run/cloudxr.env" + wait_for_file "${env_file}" "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR did not write ${env_file}" + } + wait_for_log_line 'CloudXR runtime:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR runtime did not report running" + } + wait_for_log_line 'CloudXR WSS proxy:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR WSS proxy did not report running" + } + assert_clean_log "CloudXR server log" "${CLOUDXR_LOG}" + + log "Step 4: firewall allow-list is a machine configuration step; CI validates the local WSS proxy port instead" + check_wss_proxy_port || fail "WSS proxy port ${PROXY_PORT} is not reachable on localhost" + + log "Step 5: desktop browser/IWER is the documented headset-free client path; this CI baseline does not inspect the website" + + run_example "${env_file}" + + log "Quick Start E2E workflow completed" + write_summary "passed" "Quick Start workflow reached the documented gripper example and emitted output" +} + +main "$@" From b256827f485da9a03fc2b6568b10ba0c09d2870b Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Mon, 13 Jul 2026 23:18:43 -0400 Subject: [PATCH 02/25] ci: avoid Quick Start E2E port collisions --- scripts/run_quick_start_e2e.sh | 67 ++++++++++++++++++- src/core/cloudxr/python/oob_teleop_env.py | 25 +++++-- src/core/cloudxr/python/wss.py | 13 +++- .../python/test_oob_teleop_env.py | 33 +++++++++ 4 files changed, 128 insertions(+), 10 deletions(-) diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index 344c8acfe..6ccb15d71 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -21,7 +21,10 @@ PIP_SPEC="${QUICK_START_E2E_PIP_SPEC:-isaacteleop[cloudxr,retargeters]~=1.0.0}" PIP_EXTRA_INDEX_URL="${QUICK_START_E2E_PIP_EXTRA_INDEX_URL:-https://pypi.nvidia.com}" CLOUDXR_READY_TIMEOUT_SEC="${QUICK_START_E2E_CLOUDXR_READY_TIMEOUT_SEC:-180}" EXAMPLE_TIMEOUT_SEC="${QUICK_START_E2E_EXAMPLE_TIMEOUT_SEC:-75}" -PROXY_PORT="${QUICK_START_E2E_WSS_PROXY_PORT:-48322}" +DEFAULT_CLOUDXR_SERVER_PORT=49100 +DEFAULT_WSS_PROXY_PORT=48322 +RUNTIME_PORT="${QUICK_START_E2E_RUNTIME_PORT:-}" +PROXY_PORT="${QUICK_START_E2E_WSS_PROXY_PORT:-}" CLOUDXR_LOG="${ARTIFACT_DIR}/cloudxr-server.log" EXAMPLE_LOG="${ARTIFACT_DIR}/gripper-retargeting-example.log" @@ -73,6 +76,8 @@ write_summary() { "guide": $(json_escape "${GUIDE_PATH}"), "cloudxr_log": $(json_escape "${CLOUDXR_LOG}"), "example_log": $(json_escape "${EXAMPLE_LOG}"), + "cloudxr_server_port": $(json_escape "${NV_CXR_SERVER_PORT:-}"), + "wss_proxy_port": $(json_escape "${PROXY_PORT:-}"), "gripper_output_lines": ${gripper_lines} } JSON @@ -140,6 +145,55 @@ resolve_wheel() { printf '%s\n' "${wheels[0]}" } +port_is_available() { + "${PYTHON_BIN}" - "$1" <<'PY' +import socket +import sys + +try: + port = int(sys.argv[1]) +except ValueError: + raise SystemExit(2) from None + +if not 1 <= port <= 65535: + raise SystemExit(2) + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("", port)) + except OSError: + raise SystemExit(1) from None +PY +} + +pick_free_port() { + "${PYTHON_BIN}" - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + print(sock.getsockname()[1]) +PY +} + +resolve_port() { + local requested="$1" + local default_port="$2" + + if [[ -n "${requested}" ]]; then + port_is_available "${requested}" || return $? + printf '%s\n' "${requested}" + return 0 + fi + + if port_is_available "${default_port}"; then + printf '%s\n' "${default_port}" + return 0 + fi + + pick_free_port +} + install_package() { local venv_python="${VENV_DIR}/bin/python" local wheel="" @@ -232,6 +286,17 @@ main() { install_package + RUNTIME_PORT=$(resolve_port "${RUNTIME_PORT}" "${DEFAULT_CLOUDXR_SERVER_PORT}") || \ + fail "requested CloudXR runtime port ${RUNTIME_PORT} is not available" + PROXY_PORT=$(resolve_port "${PROXY_PORT}" "${DEFAULT_WSS_PROXY_PORT}") || \ + fail "requested WSS proxy port ${PROXY_PORT} is not available" + while [[ "${PROXY_PORT}" == "${RUNTIME_PORT}" ]]; do + PROXY_PORT=$(pick_free_port) + done + export NV_CXR_SERVER_PORT="${RUNTIME_PORT}" + export PROXY_PORT + log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" + log "Step 3: starting CloudXR server with --accept-eula" "${VENV_DIR}/bin/python" -m isaacteleop.cloudxr \ --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ diff --git a/src/core/cloudxr/python/oob_teleop_env.py b/src/core/cloudxr/python/oob_teleop_env.py index c8eae2d62..7f30e9d3e 100644 --- a/src/core/cloudxr/python/oob_teleop_env.py +++ b/src/core/cloudxr/python/oob_teleop_env.py @@ -104,7 +104,8 @@ def default_web_client_origin() -> str: USB_HOST = "127.0.0.1" # serverIP seen by the headset (its own localhost) USB_UI_DEFAULT_PORT = 8080 # HTTPS static WebXR UI (loopback) -USB_BACKEND_DEFAULT_PORT = 49100 # CloudXR backend (webrtc client direct connection) +CLOUDXR_SERVER_DEFAULT_PORT = 49100 # CloudXR runtime backend +USB_BACKEND_DEFAULT_PORT = CLOUDXR_SERVER_DEFAULT_PORT # adb reverse'd backend USB_TURN_DEFAULT_PORT = 3478 # coturn TURN server port (adb reverse'd to headset) USB_TURN_USER = "cloudxr" # TURN username USB_TURN_CREDENTIAL = "cloudxrpass" # TURN credential @@ -362,6 +363,18 @@ def wss_proxy_port() -> int: return WSS_PROXY_DEFAULT_PORT +def cloudxr_server_port() -> int: + """TCP port for the CloudXR runtime backend. + + Reads the ``NV_CXR_SERVER_PORT`` environment variable if set, else falls + back to :data:`CLOUDXR_SERVER_DEFAULT_PORT` (49100). + """ + raw = os.environ.get("NV_CXR_SERVER_PORT", "").strip() + if raw: + return parse_env_port("NV_CXR_SERVER_PORT", raw) + return CLOUDXR_SERVER_DEFAULT_PORT + + def usb_ui_port() -> int: """TCP port for the USB-local WebXR static HTTPS server. @@ -378,15 +391,15 @@ def usb_ui_port() -> int: def usb_backend_port() -> int: """TCP port for the USB-local CloudXR backend (native client direct connection). - Reads the ``USB_BACKEND_PORT`` environment variable if set, else falls - back to :data:`USB_BACKEND_DEFAULT_PORT` (49100). This port is exposed - to the headset via ``adb reverse``; override only when a host process - already owns 49100. + Reads the ``USB_BACKEND_PORT`` environment variable if set, else follows + :func:`cloudxr_server_port`. This port is exposed to the headset via + ``adb reverse``; override only when a host process already owns the + CloudXR runtime port. """ raw = os.environ.get("USB_BACKEND_PORT", "").strip() if raw: return parse_env_port("USB_BACKEND_PORT", raw) - return USB_BACKEND_DEFAULT_PORT + return cloudxr_server_port() def usb_turn_port() -> int: diff --git a/src/core/cloudxr/python/wss.py b/src/core/cloudxr/python/wss.py index 6386c7309..6b1703e46 100755 --- a/src/core/cloudxr/python/wss.py +++ b/src/core/cloudxr/python/wss.py @@ -26,6 +26,7 @@ client_ui_fields_from_env, default_initial_stream_config, oob_progress, + usb_backend_port, wss_proxy_port, ) from .oob_teleop_hub import OOB_WS_PATH @@ -508,7 +509,7 @@ async def run( log_file_path: str | Path | None, stop_future: asyncio.Future, backend_host: str = "localhost", - backend_port: int = 49100, + backend_port: int | None = None, proxy_port: int | None = None, setup_oob: bool = False, usb_local: bool = False, @@ -535,6 +536,9 @@ async def run( _extra_log.addHandler(_handler) try: + resolved_backend_port = ( + usb_backend_port() if backend_port is None else backend_port + ) resolved_port = wss_proxy_port() if proxy_port is None else proxy_port logging.getLogger("websockets").setLevel(logging.WARNING) @@ -566,7 +570,7 @@ def handler(ws): path = _normalize_request_path(ws.request.path or "/") if path == OOB_WS_PATH: return hub.handle_connection(ws) - return proxy_handler(ws, backend_host, backend_port) + return proxy_handler(ws, backend_host, resolved_backend_port) _host_client_static_dir = None if host_client: @@ -575,7 +579,10 @@ def handler(ws): _host_client_static_dir = require_web_client_static_dir() http_handler = _make_http_handler( - backend_host, backend_port, hub=hub, static_dir=_host_client_static_dir + backend_host, + resolved_backend_port, + hub=hub, + static_dir=_host_client_static_dir, ) async with ws_serve( diff --git a/src/core/cloudxr_tests/python/test_oob_teleop_env.py b/src/core/cloudxr_tests/python/test_oob_teleop_env.py index 5514620d4..440850e95 100644 --- a/src/core/cloudxr_tests/python/test_oob_teleop_env.py +++ b/src/core/cloudxr_tests/python/test_oob_teleop_env.py @@ -11,6 +11,7 @@ import pytest from cloudxr_py_test_ns.oob_teleop_env import ( + CLOUDXR_SERVER_DEFAULT_PORT, FALLBACK_WEB_CLIENT_ORIGIN, TELEOP_WEB_CLIENT_BASE_ENV, TELEOP_WEB_CLIENT_STATIC_DIR_ENV, @@ -21,6 +22,7 @@ WSS_PROXY_DEFAULT_PORT, build_headset_bookmark_url, client_ui_fields_from_env, + cloudxr_server_port, default_initial_stream_config, default_web_client_origin, guess_lan_ipv4, @@ -42,6 +44,7 @@ def clear_teleop_env(monkeypatch: pytest.MonkeyPatch) -> None: """Remove all teleop environment variables so tests start from a clean slate.""" keys = ( "PROXY_PORT", + "NV_CXR_SERVER_PORT", "USB_UI_PORT", "USB_BACKEND_PORT", "USB_TURN_PORT", @@ -72,6 +75,28 @@ def test_wss_proxy_port_from_env( assert wss_proxy_port() == 50000 +def test_cloudxr_server_port_default(clear_teleop_env: None) -> None: + """CloudXR server port returns the runtime default when NV_CXR_SERVER_PORT is unset.""" + assert cloudxr_server_port() == CLOUDXR_SERVER_DEFAULT_PORT + + +def test_cloudxr_server_port_from_env( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """NV_CXR_SERVER_PORT env var overrides the default runtime backend port.""" + monkeypatch.setenv("NV_CXR_SERVER_PORT", "49210") + assert cloudxr_server_port() == 49210 + + +def test_cloudxr_server_port_invalid_env_raises( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """Non-integer NV_CXR_SERVER_PORT raises ValueError mentioning the variable name.""" + monkeypatch.setenv("NV_CXR_SERVER_PORT", "not-a-port") + with pytest.raises(ValueError, match="NV_CXR_SERVER_PORT"): + cloudxr_server_port() + + def test_usb_ui_port_default(clear_teleop_env: None) -> None: """USB UI port returns the compile-time default when USB_UI_PORT is unset.""" assert usb_ui_port() == USB_UI_DEFAULT_PORT @@ -99,6 +124,14 @@ def test_usb_backend_port_default(clear_teleop_env: None) -> None: assert usb_backend_port() == USB_BACKEND_DEFAULT_PORT +def test_usb_backend_port_follows_cloudxr_server_port( + clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch +) -> None: + """USB backend port follows NV_CXR_SERVER_PORT unless USB_BACKEND_PORT overrides it.""" + monkeypatch.setenv("NV_CXR_SERVER_PORT", "49211") + assert usb_backend_port() == 49211 + + def test_usb_backend_port_from_env( clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch ) -> None: From 7eb0b3f9382527b86f97782840808cf305f0b884 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Tue, 14 Jul 2026 00:14:37 -0400 Subject: [PATCH 03/25] fix: keep WSS proxy backend on CloudXR runtime port --- src/core/cloudxr/python/env_config.py | 1 + src/core/cloudxr/python/wss.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/core/cloudxr/python/env_config.py b/src/core/cloudxr/python/env_config.py index 4408f6809..1b0848e1f 100644 --- a/src/core/cloudxr/python/env_config.py +++ b/src/core/cloudxr/python/env_config.py @@ -50,6 +50,7 @@ class EnvConfig: "NV_CXR_ENABLE_PUSH_DEVICES": "true", "NV_CXR_ENABLE_TENSOR_DATA": "true", "NV_CXR_FILE_LOGGING": "true", + "NV_CXR_SERVER_PORT": None, # optional; persisted when set in process env "NV_DEVICE_PROFILE": "auto-webrtc", } diff --git a/src/core/cloudxr/python/wss.py b/src/core/cloudxr/python/wss.py index 6b1703e46..cec5cb1ed 100755 --- a/src/core/cloudxr/python/wss.py +++ b/src/core/cloudxr/python/wss.py @@ -23,10 +23,10 @@ run_oob_connect, ) from .oob_teleop_env import ( + cloudxr_server_port, client_ui_fields_from_env, default_initial_stream_config, oob_progress, - usb_backend_port, wss_proxy_port, ) from .oob_teleop_hub import OOB_WS_PATH @@ -537,7 +537,7 @@ async def run( try: resolved_backend_port = ( - usb_backend_port() if backend_port is None else backend_port + cloudxr_server_port() if backend_port is None else backend_port ) resolved_port = wss_proxy_port() if proxy_port is None else proxy_port From 85be58c0310482fd4b39885dd4f2db1988aaf54b Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Tue, 14 Jul 2026 01:17:26 -0400 Subject: [PATCH 04/25] fix: stream quick start CloudXR readiness logs --- .github/workflows/build-ubuntu.yml | 6 +++++- scripts/run_quick_start_e2e.sh | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index c33866a07..5bdb27534 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -567,7 +567,11 @@ jobs: uses: actions/upload-artifact@v6 with: name: quick-start-e2e-x64-py3.11 - path: ${{ runner.temp }}/isaacteleop-quick-start-e2e/ + path: | + ${{ runner.temp }}/isaacteleop-quick-start-e2e/*.log + ${{ runner.temp }}/isaacteleop-quick-start-e2e/*.json + ${{ runner.temp }}/isaacteleop-quick-start-e2e/cloudxr/run/cloudxr.env + ${{ runner.temp }}/isaacteleop-quick-start-e2e/cloudxr/logs/**/*.log if-no-files-found: warn retention-days: 7 diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index 6ccb15d71..600406306 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -298,7 +298,7 @@ main() { log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" log "Step 3: starting CloudXR server with --accept-eula" - "${VENV_DIR}/bin/python" -m isaacteleop.cloudxr \ + PYTHONUNBUFFERED=1 "${VENV_DIR}/bin/python" -u -m isaacteleop.cloudxr \ --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ --accept-eula \ > "${CLOUDXR_LOG}" 2>&1 & From fd0afdf6ecf861b7e1b8ec1d12152f61404db883 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:17:23 -0400 Subject: [PATCH 05/25] fix: reuse sourced CloudXR runtime # Conflicts: # src/core/cloudxr/python/launcher.py # src/core/cloudxr_tests/python/test_launcher.py --- src/core/cloudxr/python/launcher.py | 37 ++++++++----- .../cloudxr_tests/python/test_launcher.py | 55 +++++++++++++++++++ 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index 41ef22d93..163ac5a48 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -167,12 +167,8 @@ def __init__( self._cleanup_stale_runtime(env_cfg) - # The worker imports asyncio (via isaacteleop.cloudxr.runtime), which imports - # Python's ssl and loads the SYSTEM OpenSSL before the native stack dlopens the - # bundled one. Two OpenSSL builds in one process crash (SIGSEGV) inside - # SSL_CTX_use_certificate when the DTLS transport comes up on client connect. - # LD_PRELOAD the bundled libraries so every OpenSSL symbol in the worker - # resolves to the version libNvStreamServer.so was built against. + # Keep the runtime worker on the bundled OpenSSL libraries that the + # native CloudXR stack was built against. worker_env = os.environ.copy() native_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), "native") bundled_ssl = [ @@ -240,11 +236,11 @@ def add_launch_cloudxr_runtime_argument(parser: argparse.ArgumentParser) -> None parser.add_argument( "--launch-cloudxr-runtime", action=argparse.BooleanOptionalAction, - default=True, + default=None, 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)." + "By default, reuse a sourced CloudXR runtime environment when present " + "and launch otherwise." ), ) @@ -382,6 +378,21 @@ def _resolve_start_wss_proxy( return start_wss_proxy return bool(getattr(args, "launch_wss_proxy", True)) + @staticmethod + def _has_sourced_cloudxr_runtime_env() -> bool: + """Return true when the current shell looks sourced from ``cloudxr.env``.""" + return bool( + os.environ.get("XR_RUNTIME_JSON") and os.environ.get("NV_CXR_RUNTIME_DIR") + ) + + @staticmethod + def _resolve_launch_cloudxr_runtime(args: argparse.Namespace) -> bool: + """Resolve auto-launch behavior for documented two-terminal workflows.""" + launch_cloudxr_runtime = getattr(args, "launch_cloudxr_runtime", None) + if launch_cloudxr_runtime is not None: + return bool(launch_cloudxr_runtime) + return not CloudXRLauncher._has_sourced_cloudxr_runtime_env() + @staticmethod def launch_context( args: argparse.Namespace, @@ -395,10 +406,10 @@ def launch_context( host_client: bool = False, start_wss_proxy: bool | None = None, ) -> contextlib.AbstractContextManager[CloudXRLauncher | None]: - """Start :class:`CloudXRLauncher` when ``args.launch_cloudxr_runtime`` is true. + """Start :class:`CloudXRLauncher` when runtime launch resolves true. Returns :func:`contextlib.nullcontext` when ``args.launch_cloudxr_runtime`` is - false so callers can always use ``with CloudXRLauncher.launch_context(args):``. + false or when a sourced runtime should be reused. ``install_dir``, ``env_config``, ``device_profile``, ``accept_eula``, and ``start_wss_proxy`` default to the values registered by @@ -407,7 +418,7 @@ def launch_context( ``accept_eula``, pass ``False`` to force-disable even when the CLI flag is set. """ - if not args.launch_cloudxr_runtime: + if not CloudXRLauncher._resolve_launch_cloudxr_runtime(args): return contextlib.nullcontext(None) return CloudXRLauncher( install_dir=CloudXRLauncher._resolve_install_dir(args, install_dir), @@ -462,7 +473,7 @@ def stop(self) -> None: logger.info("CloudXR runtime process stopped") def health_check(self) -> None: - """Verify that the runtime process and WSS proxy are healthy. + """Verify that the runtime process and optional WSS proxy are healthy. Returns immediately when the runtime is running and, when the WSS proxy was started, its background thread is alive. Raises diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index 711cb389a..ff67728a0 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -399,6 +399,8 @@ def test_add_launcher_arguments_defaults(self) -> None: args = parser.parse_args([]) assert args.cloudxr_env_config is None assert args.accept_eula is False + assert args.cloudxr_device_profile == DEFAULT_DEVICE_PROFILE + assert args.launch_cloudxr_runtime is None assert args.launch_wss_proxy is True def test_add_cloudxr_device_profile_argument_default(self) -> None: @@ -417,6 +419,12 @@ 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 None + + def test_add_launch_cloudxr_runtime_argument_launch(self) -> None: + parser = argparse.ArgumentParser() + CloudXRLauncher.add_launch_cloudxr_runtime_argument(parser) + args = parser.parse_args(["--launch-cloudxr-runtime"]) assert args.launch_cloudxr_runtime is True def test_add_launch_cloudxr_runtime_argument_no_launch(self) -> None: @@ -442,6 +450,49 @@ def test_launch_context_skips_when_disabled(self) -> None: with CloudXRLauncher.launch_context(args) as launcher: assert launcher is None + def test_resolve_launch_cloudxr_runtime_auto_launches_without_env( + self, monkeypatch + ) -> None: + monkeypatch.delenv("XR_RUNTIME_JSON", raising=False) + monkeypatch.delenv("NV_CXR_RUNTIME_DIR", raising=False) + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True + + def test_resolve_launch_cloudxr_runtime_auto_reuses_sourced_env( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + + def test_resolve_launch_cloudxr_runtime_explicit_overrides_env( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=True) + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True + + args.launch_cloudxr_runtime = False + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + + def test_launch_context_reuses_sourced_runtime_in_auto_mode( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + 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( @@ -489,6 +540,10 @@ def test_launch_context_passes_start_wss_proxy_kwarg(self, tmp_path) -> None: mocks["wss"].assert_not_called() mocks["proc"].poll.return_value = 0 + def test_constructor_rejects_wss_features_without_wss_proxy(self) -> None: + with pytest.raises(ValueError, match="requires the WSS proxy"): + CloudXRLauncher(start_wss_proxy=False, host_client=True) + def test_resolve_accept_eula_none_falls_back_to_args(self) -> None: args = argparse.Namespace(accept_eula=True) assert CloudXRLauncher._resolve_accept_eula(args) is True From 6678c06c1630a9b87c7322c850ba1ae678f16db2 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Tue, 14 Jul 2026 19:50:22 -0400 Subject: [PATCH 06/25] fix: restore USB backend default port Signed-off-by: Andrew Russell --- .github/workflows/build-ubuntu.yml | 3 --- src/core/cloudxr/python/oob_teleop_env.py | 10 +++++----- src/core/cloudxr_tests/python/test_oob_teleop_env.py | 8 -------- 3 files changed, 5 insertions(+), 16 deletions(-) diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 5bdb27534..050f18c10 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -530,9 +530,6 @@ jobs: # Runs the documented Quick Start workflow against the candidate wheel on a # GPU runner: install package, launch CloudXR, source the generated env, run # the documented gripper retargeting example, and upload the logs/summary. - # The desktop browser/IWER path remains the documented headset-free client - # path; this baseline validates the runnable guide steps without scraping - # the published documentation site. runs-on: [self-hosted, linux, gpu, x64] needs: build-ubuntu timeout-minutes: 30 diff --git a/src/core/cloudxr/python/oob_teleop_env.py b/src/core/cloudxr/python/oob_teleop_env.py index 7f30e9d3e..25e2b14a8 100644 --- a/src/core/cloudxr/python/oob_teleop_env.py +++ b/src/core/cloudxr/python/oob_teleop_env.py @@ -391,15 +391,15 @@ def usb_ui_port() -> int: def usb_backend_port() -> int: """TCP port for the USB-local CloudXR backend (native client direct connection). - Reads the ``USB_BACKEND_PORT`` environment variable if set, else follows - :func:`cloudxr_server_port`. This port is exposed to the headset via - ``adb reverse``; override only when a host process already owns the - CloudXR runtime port. + Reads the ``USB_BACKEND_PORT`` environment variable if set, else falls back + to :data:`USB_BACKEND_DEFAULT_PORT` (49100). This port is exposed to the + headset via ``adb reverse``; override only when a host process already owns + the CloudXR runtime port. """ raw = os.environ.get("USB_BACKEND_PORT", "").strip() if raw: return parse_env_port("USB_BACKEND_PORT", raw) - return cloudxr_server_port() + return USB_BACKEND_DEFAULT_PORT def usb_turn_port() -> int: diff --git a/src/core/cloudxr_tests/python/test_oob_teleop_env.py b/src/core/cloudxr_tests/python/test_oob_teleop_env.py index 440850e95..07a6db01c 100644 --- a/src/core/cloudxr_tests/python/test_oob_teleop_env.py +++ b/src/core/cloudxr_tests/python/test_oob_teleop_env.py @@ -124,14 +124,6 @@ def test_usb_backend_port_default(clear_teleop_env: None) -> None: assert usb_backend_port() == USB_BACKEND_DEFAULT_PORT -def test_usb_backend_port_follows_cloudxr_server_port( - clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch -) -> None: - """USB backend port follows NV_CXR_SERVER_PORT unless USB_BACKEND_PORT overrides it.""" - monkeypatch.setenv("NV_CXR_SERVER_PORT", "49211") - assert usb_backend_port() == 49211 - - def test_usb_backend_port_from_env( clear_teleop_env: None, monkeypatch: pytest.MonkeyPatch ) -> None: From ff8db36203f65dc7b83567bcdf3158c3df0655a6 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:18:35 -0400 Subject: [PATCH 07/25] test: add Quick Start client validation probe Signed-off-by: Andrew Russell # Conflicts: # src/core/cloudxr/python/launcher.py # src/core/cloudxr_tests/python/test_launcher.py --- .github/workflows/build-ubuntu.yml | 48 +++ scripts/quick_start_client_probe.py | 333 ++++++++++++++++++ scripts/run_quick_start_e2e.sh | 71 +++- src/core/cloudxr/python/__main__.py | 26 ++ src/core/cloudxr/python/launcher.py | 22 +- src/core/cloudxr/python/wss.py | 3 +- .../cloudxr_tests/python/test_launcher.py | 11 + 7 files changed, 505 insertions(+), 9 deletions(-) create mode 100755 scripts/quick_start_client_probe.py diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index 050f18c10..c3a994f5b 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -572,6 +572,53 @@ jobs: if-no-files-found: warn retention-days: 7 + test-quick-start-client-e2e: + # Follow-up Quick Start validation: launch the locally hosted desktop/IWER + # client in a Chromium browser, click CONNECT, and require server-side OOB + # state to report a connected streaming client. + runs-on: [self-hosted, linux, gpu, x64] + needs: build-ubuntu + timeout-minutes: 35 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Download isaacteleop wheel (x64, py3.11) + uses: actions/download-artifact@v7 + with: + name: isaacteleop-wheels-x64-py3.11 + path: quick-start-wheels + + - name: Run Quick Start client E2E workflow + env: + CI: true + QUICK_START_E2E_REQUIRE_WHEEL: "1" + QUICK_START_E2E_VALIDATE_CLIENT: "1" + QUICK_START_E2E_WHEEL_DIR: quick-start-wheels + QUICK_START_E2E_ARTIFACT_DIR: ${{ runner.temp }}/isaacteleop-quick-start-client-e2e + run: ./scripts/run_quick_start_e2e.sh + + - name: Upload Quick Start client E2E artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v6 + with: + name: quick-start-client-e2e-x64-py3.11 + path: | + ${{ runner.temp }}/isaacteleop-quick-start-client-e2e/*.log + ${{ runner.temp }}/isaacteleop-quick-start-client-e2e/*.json + ${{ runner.temp }}/isaacteleop-quick-start-client-e2e/cloudxr/run/cloudxr.env + ${{ runner.temp }}/isaacteleop-quick-start-client-e2e/cloudxr/logs/**/*.log + if-no-files-found: warn + retention-days: 7 + test-teleop-ros2: runs-on: [self-hosted, linux, gpu, "${{ matrix.arch }}"] needs: build-ubuntu @@ -878,6 +925,7 @@ jobs: - test-viz-sanitizers - test-cloudxr - test-quick-start-e2e + - test-quick-start-client-e2e - test-teleop-ros2 if: ${{ always() }} diff --git a/scripts/quick_start_client_probe.py b/scripts/quick_start_client_probe.py new file mode 100755 index 000000000..699027c30 --- /dev/null +++ b/scripts/quick_start_client_probe.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +"""Probe the Quick Start browser client through CDP and OOB hub state. + +This is the desktop-browser validation layer for the Quick Start workflow: + +1. Launch Chromium/Chrome against the locally hosted web client. +2. Click CONNECT through Chrome DevTools Protocol. +3. Poll the WSS proxy's OOB state endpoint until the client reports + connected streaming state. Metrics are recorded when available. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import shutil +import ssl +import subprocess +import sys +import tempfile +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + + +class ProbeError(RuntimeError): + """Raised when the browser client cannot prove the expected signal.""" + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--client-url", required=True, help="Hosted web client URL") + parser.add_argument( + "--state-url", + required=True, + help="OOB state endpoint, for example https://127.0.0.1:48322/api/oob/v1/state", + ) + parser.add_argument( + "--browser", + default=None, + help=( + "Chromium/Chrome executable. Defaults to QUICK_START_E2E_BROWSER " + "or PATH lookup." + ), + ) + parser.add_argument( + "--timeout", + type=float, + default=90.0, + help="Seconds to wait for browser connection and OOB state.", + ) + parser.add_argument( + "--require-metrics", + action="store_true", + help="Require the OOB streaming state to include non-empty metrics.", + ) + parser.add_argument( + "--summary-json", + type=Path, + default=None, + help="Optional file to write the last successful OOB state snapshot.", + ) + return parser.parse_args() + + +def find_browser(explicit: str | None) -> str: + if explicit: + return explicit + for name in ( + "google-chrome", + "google-chrome-stable", + "chromium", + "chromium-browser", + "microsoft-edge", + "msedge", + ): + path = shutil.which(name) + if path: + return path + raise ProbeError( + "No Chromium-based browser found. Set QUICK_START_E2E_BROWSER to the " + "Chrome/Chromium executable on this runner." + ) + + +def read_json(url: str, *, ssl_context: ssl.SSLContext | None = None) -> Any: + request = urllib.request.Request( + url, headers={"User-Agent": "quick-start-client-probe"} + ) + with urllib.request.urlopen(request, timeout=5.0, context=ssl_context) as response: + return json.loads(response.read().decode("utf-8")) + + +def wait_for_devtools_port( + user_data_dir: Path, proc: subprocess.Popen, timeout: float +) -> int: + active_port_file = user_data_dir / "DevToolsActivePort" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if proc.poll() is not None: + raise ProbeError( + f"Browser exited before DevTools became ready (rc={proc.returncode})" + ) + if active_port_file.exists(): + lines = active_port_file.read_text(encoding="utf-8").splitlines() + if lines: + return int(lines[0]) + time.sleep(0.25) + raise ProbeError("Timed out waiting for Chrome DevToolsActivePort") + + +def find_client_tab(devtools_port: int, client_url: str, timeout: float) -> str: + expected = urllib.parse.urlparse(client_url) + deadline = time.monotonic() + timeout + last_tabs: Any = None + while time.monotonic() < deadline: + tabs = read_json(f"http://127.0.0.1:{devtools_port}/json/list") + last_tabs = tabs + for tab in tabs: + ws_url = tab.get("webSocketDebuggerUrl") + current_url = tab.get("url") or "" + parsed = urllib.parse.urlparse(current_url) + if not ws_url: + continue + if parsed.scheme == expected.scheme and parsed.netloc == expected.netloc: + return ws_url + if current_url.startswith("chrome-error://"): + return ws_url + time.sleep(0.5) + raise ProbeError( + f"Could not find hosted client tab in DevTools list: {last_tabs!r}" + ) + + +async def cdp_click_connect(ws_url: str, timeout: float) -> None: + from websockets.asyncio.client import connect as ws_connect + + next_id = 0 + + async def send( + ws, method: str, params: dict[str, Any] | None = None + ) -> dict[str, Any]: + nonlocal next_id + next_id += 1 + await ws.send( + json.dumps({"id": next_id, "method": method, "params": params or {}}) + ) + while True: + raw = await ws.recv() + msg = json.loads(raw) + if msg.get("id") == next_id: + if "error" in msg: + raise ProbeError(f"CDP {method} failed: {msg['error']}") + return msg + + async with ws_connect(ws_url) as ws: + for method, params in ( + ("Page.enable", None), + ("Runtime.enable", None), + ("Security.enable", None), + ("Security.setIgnoreCertificateErrors", {"ignore": True}), + ): + try: + await send(ws, method, params) + except ProbeError: + if method.startswith("Security."): + continue + raise + + await send(ws, "Page.bringToFront") + deadline = time.monotonic() + timeout + last_state: Any = None + while time.monotonic() < deadline: + result = await send( + ws, + "Runtime.evaluate", + { + "expression": """(() => { + const btn = document.getElementById('startButton'); + const errorNode = document.getElementById('errorMessageText'); + const errorText = errorNode?.textContent?.trim() || ''; + if (!btn) return {state: 'loading', errorText}; + const text = btn.textContent?.trim() || ''; + const disabled = Boolean(btn.disabled); + if (text.toUpperCase().includes('FAIL')) { + return {state: 'failed', text, disabled, errorText}; + } + if (disabled || text.toUpperCase() !== 'CONNECT') { + return {state: 'initializing', text, disabled, errorText}; + } + const rc = btn.getBoundingClientRect(); + return { + state: 'ready', + text, + disabled, + x: rc.left + rc.width / 2, + y: rc.top + rc.height / 2, + errorText, + }; + })()""", + "returnByValue": True, + }, + ) + value = (result.get("result") or {}).get("result", {}).get("value") or {} + last_state = value + if value.get("state") == "ready": + x = float(value["x"]) + y = float(value["y"]) + for event_type in ("mousePressed", "mouseReleased"): + await send( + ws, + "Input.dispatchMouseEvent", + { + "type": event_type, + "x": x, + "y": y, + "button": "left", + "clickCount": 1, + }, + ) + await send( + ws, + "Runtime.evaluate", + {"expression": "document.getElementById('startButton')?.click()"}, + ) + return + if value.get("state") == "failed": + raise ProbeError(f"Client capability check failed: {value!r}") + await asyncio.sleep(0.5) + + raise ProbeError(f"CONNECT did not become actionable: {last_state!r}") + + +def wait_for_streaming_state( + state_url: str, + *, + timeout: float, + ssl_context: ssl.SSLContext, + require_metrics: bool, +) -> dict[str, Any]: + deadline = time.monotonic() + timeout + last_state: Any = None + while time.monotonic() < deadline: + try: + state = read_json(state_url, ssl_context=ssl_context) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError) as exc: + last_state = f"{type(exc).__name__}: {exc}" + time.sleep(1.0) + continue + + last_state = state + for headset in state.get("headsets", []): + if not headset.get("connected") or not headset.get("streaming"): + continue + metrics = headset.get("metricsByCadence") or {} + has_metrics = any( + (entry.get("metrics") or {}) for entry in metrics.values() + ) + headset["metricsObserved"] = has_metrics + if has_metrics or not require_metrics: + return state + time.sleep(1.0) + + expectation = ( + "streaming client metrics" if require_metrics else "streaming client state" + ) + raise ProbeError(f"Timed out waiting for {expectation}. Last state: {last_state!r}") + + +def main() -> int: + args = parse_args() + browser = find_browser(args.browser) + ssl_context = ssl._create_unverified_context() + + with tempfile.TemporaryDirectory(prefix="quick-start-client-") as tmp: + user_data_dir = Path(tmp) / "profile" + user_data_dir.mkdir() + proc = subprocess.Popen( + [ + browser, + "--headless=new", + "--disable-gpu", + "--no-sandbox", + "--ignore-certificate-errors", + "--allow-insecure-localhost", + "--remote-debugging-port=0", + f"--user-data-dir={user_data_dir}", + args.client_url, + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.STDOUT, + ) + try: + devtools_port = wait_for_devtools_port(user_data_dir, proc, args.timeout) + ws_url = find_client_tab(devtools_port, args.client_url, args.timeout) + asyncio.run(cdp_click_connect(ws_url, args.timeout)) + state = wait_for_streaming_state( + args.state_url, + timeout=args.timeout, + ssl_context=ssl_context, + require_metrics=args.require_metrics, + ) + if args.summary_json: + args.summary_json.parent.mkdir(parents=True, exist_ok=True) + args.summary_json.write_text( + json.dumps(state, indent=2), encoding="utf-8" + ) + finally: + proc.terminate() + try: + proc.wait(timeout=10) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=10) + + print("Quick Start client probe passed: streaming client state observed.") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except ProbeError as exc: + print(f"quick_start_client_probe.py: error: {exc}", file=sys.stderr) + raise SystemExit(1) from exc diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index 600406306..865456492 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -21,6 +21,8 @@ PIP_SPEC="${QUICK_START_E2E_PIP_SPEC:-isaacteleop[cloudxr,retargeters]~=1.0.0}" PIP_EXTRA_INDEX_URL="${QUICK_START_E2E_PIP_EXTRA_INDEX_URL:-https://pypi.nvidia.com}" CLOUDXR_READY_TIMEOUT_SEC="${QUICK_START_E2E_CLOUDXR_READY_TIMEOUT_SEC:-180}" EXAMPLE_TIMEOUT_SEC="${QUICK_START_E2E_EXAMPLE_TIMEOUT_SEC:-75}" +CLIENT_PROBE_TIMEOUT_SEC="${QUICK_START_E2E_CLIENT_PROBE_TIMEOUT_SEC:-90}" +VALIDATE_CLIENT="${QUICK_START_E2E_VALIDATE_CLIENT:-0}" DEFAULT_CLOUDXR_SERVER_PORT=49100 DEFAULT_WSS_PROXY_PORT=48322 RUNTIME_PORT="${QUICK_START_E2E_RUNTIME_PORT:-}" @@ -28,6 +30,8 @@ PROXY_PORT="${QUICK_START_E2E_WSS_PROXY_PORT:-}" CLOUDXR_LOG="${ARTIFACT_DIR}/cloudxr-server.log" EXAMPLE_LOG="${ARTIFACT_DIR}/gripper-retargeting-example.log" +CLIENT_PROBE_LOG="${ARTIFACT_DIR}/client-probe.log" +CLIENT_STATE_JSON="${ARTIFACT_DIR}/client-oob-state.json" SUMMARY_JSON="${ARTIFACT_DIR}/quick-start-e2e-summary.json" cloudxr_pid="" @@ -76,6 +80,9 @@ write_summary() { "guide": $(json_escape "${GUIDE_PATH}"), "cloudxr_log": $(json_escape "${CLOUDXR_LOG}"), "example_log": $(json_escape "${EXAMPLE_LOG}"), + "client_probe_log": $(json_escape "${CLIENT_PROBE_LOG}"), + "client_state_json": $(json_escape "${CLIENT_STATE_JSON}"), + "client_validation": $(json_escape "${VALIDATE_CLIENT}"), "cloudxr_server_port": $(json_escape "${NV_CXR_SERVER_PORT:-}"), "wss_proxy_port": $(json_escape "${PROXY_PORT:-}"), "gripper_output_lines": ${gripper_lines} @@ -186,6 +193,11 @@ resolve_port() { return 0 fi + if [[ "${CI:-}" == "true" ]]; then + pick_free_port + return 0 + fi + if port_is_available "${default_port}"; then printf '%s\n' "${default_port}" return 0 @@ -239,6 +251,46 @@ with socket.create_connection(("127.0.0.1", port), timeout=5.0): PY } +wait_for_wss_proxy_port() { + local timeout="$1" + local deadline=$((SECONDS + timeout)) + + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if check_wss_proxy_port >/dev/null 2>&1; then + return 0 + fi + if [[ -n "${cloudxr_pid}" ]] && ! kill -0 "${cloudxr_pid}" 2>/dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +run_client_probe() { + local client_url + local state_url + + client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72" + state_url="https://127.0.0.1:${PROXY_PORT}/api/oob/v1/state" + + log "Step 5: connecting hosted desktop/IWER client and waiting for OOB streaming state" + local probe_rc=0 + "${VENV_DIR}/bin/python" "${ROOT_DIR}/scripts/quick_start_client_probe.py" \ + --client-url "${client_url}" \ + --state-url "${state_url}" \ + --timeout "${CLIENT_PROBE_TIMEOUT_SEC}" \ + --summary-json "${CLIENT_STATE_JSON}" \ + > "${CLIENT_PROBE_LOG}" 2>&1 || probe_rc=$? + + if (( probe_rc != 0 )); then + log "===== client probe log =====" + cat "${CLIENT_PROBE_LOG}" || true + fail "desktop/IWER client probe exited with status ${probe_rc}" + fi +} + run_example() { local env_file="$1" @@ -297,10 +349,17 @@ main() { export PROXY_PORT log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" + local cloudxr_args=( + --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ + --accept-eula + ) + if [[ "${VALIDATE_CLIENT}" == "1" ]]; then + cloudxr_args+=(--host-client --enable-oob-hub) + fi + log "Step 3: starting CloudXR server with --accept-eula" PYTHONUNBUFFERED=1 "${VENV_DIR}/bin/python" -u -m isaacteleop.cloudxr \ - --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ - --accept-eula \ + "${cloudxr_args[@]}" \ > "${CLOUDXR_LOG}" 2>&1 & cloudxr_pid=$! @@ -323,9 +382,13 @@ main() { assert_clean_log "CloudXR server log" "${CLOUDXR_LOG}" log "Step 4: firewall allow-list is a machine configuration step; CI validates the local WSS proxy port instead" - check_wss_proxy_port || fail "WSS proxy port ${PROXY_PORT} is not reachable on localhost" + wait_for_wss_proxy_port 30 || fail "WSS proxy port ${PROXY_PORT} is not reachable on localhost" - log "Step 5: desktop browser/IWER is the documented headset-free client path; this CI baseline does not inspect the website" + if [[ "${VALIDATE_CLIENT}" == "1" ]]; then + run_client_probe + else + log "Step 5: desktop browser/IWER is the documented headset-free client path; this CI baseline does not inspect the website" + fi run_example "${env_file}" diff --git a/src/core/cloudxr/python/__main__.py b/src/core/cloudxr/python/__main__.py index 59a01337e..02a7bd00b 100644 --- a/src/core/cloudxr/python/__main__.py +++ b/src/core/cloudxr/python/__main__.py @@ -61,6 +61,16 @@ def _parse_args() -> argparse.Namespace: action="store_true", help="Accept the NVIDIA CloudXR EULA non-interactively (e.g. for CI or containers).", ) + parser.add_argument( + "--enable-oob-hub", + action="store_true", + default=False, + help=( + "Enable the OOB teleop control hub without USB adb automation. " + "Use with --host-client for desktop-browser validation that reads " + "client connection state from the WSS proxy API." + ), + ) parser.add_argument( "--setup-oob", action="store_true", @@ -135,6 +145,8 @@ def main() -> None: # # (none) Plain: headset navigates to GitHub Pages URL over WiFi. # --host-client Client served at https://:/client/; no adb/TURN. + # --enable-oob-hub --host-client + # OOB hub + local client; no adb/TURN. # --setup-oob OOB hub + CDP automation; GitHub Pages URL. # --setup-oob --host-client OOB hub + CDP; client served at /client/ on the WSS proxy. # --setup-oob --usb-local OOB hub + CDP; adb-reverse + coturn + loopback HTTPS. @@ -199,6 +211,7 @@ def main() -> None: install_dir=args.cloudxr_install_dir, env_config=args.cloudxr_env_config, accept_eula=args.accept_eula, + enable_oob_hub=args.enable_oob_hub, setup_oob=args.setup_oob, usb_local=args.usb_local, host_client=args.host_client, @@ -242,6 +255,19 @@ def main() -> None: lan_host=_oob_lan_host, web_client_base=_hosted_client_url, ) + elif args.enable_oob_hub: + _port = wss_proxy_port() + print( + " oob: \033[32menabled\033[0m (hub only; no adb automation)" + ) + print( + f" state: \033[36mhttps://localhost:{_port}/api/oob/v1/state\033[0m" + ) + if _hosted_client_url is not None: + print( + f" client: \033[36m{_hosted_client_url}\033[0m " + "\033[90m(open locally; add oobEnable=1, serverIP, and port query params)\033[0m" + ) else: if _hosted_client_url is not None: _label = "USB-local" if args.usb_local else "hosted locally" diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index 163ac5a48..3d242b9c7 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -78,6 +78,7 @@ def __init__( env_config: str | Path | None = None, device_profile: str = DEFAULT_DEVICE_PROFILE, accept_eula: bool = False, + enable_oob_hub: bool = False, setup_oob: bool = False, usb_local: bool = False, host_client: bool = False, @@ -100,6 +101,9 @@ def __init__( accept_eula: Accept the NVIDIA CloudXR EULA non-interactively. When ``False`` and the EULA marker does not exist, the user is prompted on stdin. + enable_oob_hub: Enable the OOB teleop control hub without + USB adb automation. Useful for desktop-browser validation + that reads hub state via the WSS proxy. setup_oob: Enable the OOB teleop control hub and USB adb automation in the WSS proxy. usb_local: Route teleop traffic over USB headset loopback via @@ -121,23 +125,29 @@ def __init__( RuntimeError: If the EULA is not accepted or the runtime fails to start within the timeout. ValueError: If *start_wss_proxy* is ``False`` while any WSS-only - option (*setup_oob*, *usb_local*, or *host_client*) is set. + option (*enable_oob_hub*, *setup_oob*, *usb_local*, or + *host_client*) is set. """ self._install_dir = install_dir self._env_config = str(env_config) if env_config is not None else None self._device_profile = device_profile self._accept_eula = accept_eula + self._enable_oob_hub = enable_oob_hub self._setup_oob = setup_oob self._usb_local = usb_local self._host_client = host_client self._start_wss_proxy = start_wss_proxy if not self._start_wss_proxy and ( - self._setup_oob or self._usb_local or self._host_client + self._enable_oob_hub + or self._setup_oob + or self._usb_local + or self._host_client ): raise ValueError( - "start_wss_proxy=False is incompatible with setup_oob, " - "usb_local, and host_client (those features require the WSS proxy)" + "start_wss_proxy=False is incompatible with enable_oob_hub, " + "setup_oob, usb_local, and host_client " + "(those features require the WSS proxy)" ) if self._usb_local or self._host_client: @@ -401,6 +411,7 @@ def launch_context( env_config: str | Path | None = None, device_profile: str | None = None, accept_eula: bool | None = None, + enable_oob_hub: bool = False, setup_oob: bool = False, usb_local: bool = False, host_client: bool = False, @@ -427,6 +438,7 @@ def launch_context( args, device_profile ), accept_eula=CloudXRLauncher._resolve_accept_eula(args, accept_eula), + enable_oob_hub=enable_oob_hub, setup_oob=setup_oob, usb_local=usb_local, host_client=host_client, @@ -674,6 +686,7 @@ def _start_wss_proxy_thread(self, log_path: Path) -> None: self._wss_stop_future = stop_future setup_oob = self._setup_oob + enable_oob_hub = self._enable_oob_hub usb_local = self._usb_local host_client = self._host_client @@ -684,6 +697,7 @@ def _run_wss() -> None: wss_run( log_file_path=log_path, stop_future=stop_future, + enable_oob_hub=enable_oob_hub, setup_oob=setup_oob, usb_local=usb_local, host_client=host_client, diff --git a/src/core/cloudxr/python/wss.py b/src/core/cloudxr/python/wss.py index cec5cb1ed..02ce11efb 100755 --- a/src/core/cloudxr/python/wss.py +++ b/src/core/cloudxr/python/wss.py @@ -511,6 +511,7 @@ async def run( backend_host: str = "localhost", backend_port: int | None = None, proxy_port: int | None = None, + enable_oob_hub: bool = False, setup_oob: bool = False, usb_local: bool = False, host_client: bool = False, @@ -548,7 +549,7 @@ async def run( ssl_ctx = build_ssl_context(cert_paths) hub = None - if setup_oob: + if setup_oob or enable_oob_hub: from .oob_teleop_hub import OOBControlHub # noqa: PLC0415 control_token = os.environ.get("CONTROL_TOKEN") or None diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index ff67728a0..901eb402a 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -204,6 +204,17 @@ def test_construction_rejects_wss_options_without_proxy(self, tmp_path): with pytest.raises(ValueError, match="start_wss_proxy=False"): CloudXRLauncher(start_wss_proxy=False, host_client=True) + with pytest.raises(ValueError, match="start_wss_proxy=False"): + CloudXRLauncher(start_wss_proxy=False, enable_oob_hub=True) + + def test_construction_accepts_oob_hub_without_adb_setup(self, tmp_path): + """The OOB hub can be enabled without USB adb automation.""" + with mock_launcher_deps(tmp_path, ready=True) as mocks: + launcher = CloudXRLauncher(enable_oob_hub=True) + + mocks["wss"].assert_called_once() + assert launcher._enable_oob_hub is True + assert launcher._setup_oob is False # ============================================================================ # TestLauncherStop From c62ba43ac521e2c9e6f4e6b1992c82fd2e105492 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:27:18 -0400 Subject: [PATCH 08/25] test: use hosted desktop client for Quick Start probe Signed-off-by: Andrew Russell # Conflicts: # deps/cloudxr/webxr_client/webpack.chunkNames.js # docs/source/getting_started/quick_start.rst --- .github/workflows/build-ubuntu.yml | 6 ++ deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 95 +++++-------------- .../webxr_client/webpack.chunkNames.js | 7 +- docs/source/getting_started/quick_start.rst | 36 +++++++ 4 files changed, 72 insertions(+), 72 deletions(-) diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index c3a994f5b..de371fed0 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -597,6 +597,11 @@ jobs: name: isaacteleop-wheels-x64-py3.11 path: quick-start-wheels + - name: Build hosted desktop web client + uses: ./.github/actions/build-cloudxr-web-client + with: + ngc-api-key: ${{ secrets.NGC_TELEOP_CORE_GITHUB_SERVICE_KEY }} + - name: Run Quick Start client E2E workflow env: CI: true @@ -604,6 +609,7 @@ jobs: QUICK_START_E2E_VALIDATE_CLIENT: "1" QUICK_START_E2E_WHEEL_DIR: quick-start-wheels QUICK_START_E2E_ARTIFACT_DIR: ${{ runner.temp }}/isaacteleop-quick-start-client-e2e + TELEOP_WEB_CLIENT_STATIC_DIR: ${{ github.workspace }}/deps/cloudxr/webxr_client/build run: ./scripts/run_quick_start_e2e.sh - name: Upload Quick Start client E2E artifacts diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index 30b58e8a7..130843cb9 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -23,14 +23,32 @@ declare global { } } -const IWER_version = '2.2.1'; -const IWER_DEVUI_version = '2.2.0'; - export interface IWERLoadResult { supportsImmersive: boolean; iwerLoaded: boolean; } +async function installBundledIWER(): Promise { + try { + const IWERModule = await import('iwer'); + const IWERGlobal = (IWERModule as any).default ?? IWERModule; + const XRDeviceCtor = IWERGlobal.XRDevice; + const deviceProfile = IWERGlobal.metaQuest3; + if (!XRDeviceCtor || !deviceProfile) { + console.warn('Bundled IWER module is missing XRDevice or metaQuest3.'); + return false; + } + + const device: XRDevice = new XRDeviceCtor(deviceProfile); + await device.installRuntime(); + window.xrDevice = device; + return true; + } catch (e) { + console.warn('IWER runtime install failed:', e); + return false; + } +} + export async function loadIWERIfNeeded(): Promise { let supportsImmersive = false; let iwerLoaded = false; @@ -44,74 +62,9 @@ export async function loadIWERIfNeeded(): Promise { } if (!supportsImmersive) { - console.info('Immersive mode not supported, loading IWER as fallback.'); - - // Load IWER first - const script = document.createElement('script'); - script.src = `https://unpkg.com/iwer@${IWER_version}/build/iwer.min.js`; - script.async = true; - script.integrity = 'sha384-3G2UIBh0RX9Imd3PFwcHyXbqRYAeQo9FDMgQTOLcflo9H6LDHaxADB24vKC3b+OY'; - script.crossOrigin = 'anonymous'; - - await new Promise(resolve => { - script.onload = async () => { - console.info('IWER loaded as fallback.'); - const IWERGlobal = (window as any).IWER || (globalThis as any).IWER; - if (!IWERGlobal) { - console.warn('IWER global not found after script load.'); - supportsImmersive = false; - resolve(); - return; - } - - // Load iwer-devui after IWER - const devUIScript = document.createElement('script'); - devUIScript.src = `https://unpkg.com/@iwer/devui@${IWER_DEVUI_version}/build/iwer-devui.min.js`; - devUIScript.async = true; - devUIScript.integrity = - 'sha384-gPhqycVT+bNyiNIH8kMEWFjaysw6xH9NGYwuduRzK71Ro0Tp3hXByxqAI9sWrc9T'; - devUIScript.crossOrigin = 'anonymous'; - - await new Promise(devUIResolve => { - devUIScript.onload = () => { - console.info('IWER DevUI loaded.'); - devUIResolve(); - }; - devUIScript.onerror = error => { - console.warn('Failed to load IWER DevUI:', error); - devUIResolve(); - }; - document.head.appendChild(devUIScript); - }); - - try { - const device: XRDevice = new IWERGlobal.XRDevice(IWERGlobal.metaQuest3); - - const IWER_DevUI = (window as any).IWER_DevUI || (globalThis as any).IWER_DevUI; - if (IWER_DevUI?.DevUI) { - device.installDevUI(IWER_DevUI.DevUI); - console.info('IWER DevUI initialized with XR device.'); - } else { - console.warn('IWER DevUI not found after script load, continuing without DevUI.'); - } - - await device.installRuntime(); - window.xrDevice = device; - supportsImmersive = true; - iwerLoaded = true; - } catch (e) { - console.warn('IWER runtime install failed:', e); - supportsImmersive = false; - } - resolve(); - }; - script.onerror = () => { - console.warn('Failed to load IWER.'); - supportsImmersive = false; - resolve(); - }; - document.head.appendChild(script); - }); + console.info('Immersive mode not supported, installing bundled IWER fallback.'); + iwerLoaded = await installBundledIWER(); + supportsImmersive = iwerLoaded; } return { supportsImmersive, iwerLoaded }; diff --git a/deps/cloudxr/webxr_client/webpack.chunkNames.js b/deps/cloudxr/webxr_client/webpack.chunkNames.js index 7511eed80..044531ede 100644 --- a/deps/cloudxr/webxr_client/webpack.chunkNames.js +++ b/deps/cloudxr/webxr_client/webpack.chunkNames.js @@ -9,7 +9,8 @@ * - ``bundle.emulator.js`` — desktop XR / IWER code and its transitive deps * * Routing (no per-package allowlists): - * lazy ``import()`` under ``@pmndrs/xr/dist`` → ``bundle.emulator.js`` + * lazy ``import()`` under ``@pmndrs/xr/dist`` or ``helpers/LoadIWER.ts`` → + * ``bundle.emulator.js`` * all other dynamic imports (UIKit → msdf, zustand, fonts, …) → eager → ``bundle.js`` * * The MSDF web worker is inlined via ``asset/inline`` (no separate worker file). @@ -27,6 +28,10 @@ const EMULATOR_CHUNK = 'emulator'; * @type {import('webpack').RuleSetRule[]} */ const eagerExceptEmulatorParserRules = [ + { + test: /[\\/]helpers[\\/]LoadIWER\.ts$/, + parser: { javascript: { dynamicImportMode: 'lazy' } }, + }, { test: /[\\/]@pmndrs[\\/]xr[\\/]dist[\\/]/, parser: { javascript: { dynamicImportMode: 'lazy' } }, diff --git a/docs/source/getting_started/quick_start.rst b/docs/source/getting_started/quick_start.rst index 7bb12f8be..35da1d58e 100644 --- a/docs/source/getting_started/quick_start.rst +++ b/docs/source/getting_started/quick_start.rst @@ -135,6 +135,42 @@ To inspect the resolved settings after startup: like ``--host-client`` and ``--setup-oob`` — see :doc:`/references/cloudxr`. +.. dropdown:: Optional CloudXR launch modes + + The launcher supports optional flags that can be combined to control how a + browser client connects and how the web client is delivered. + + .. list-table:: + :header-rows: 1 + :widths: 45 55 + + * - Command + - What it does + * - ``python -m isaacteleop.cloudxr`` + - Plain: headset navigates to GitHub Pages URL over WiFi. + * - ``python -m isaacteleop.cloudxr --host-client`` + - Serves the web client at ``https://:48322/client/`` via the WSS + proxy. No separate port, no USB or TURN relay required. Useful when + GitHub Pages is unreachable. + * - ``python -m isaacteleop.cloudxr --host-client --enable-oob-hub`` + - Headset-free validation mode: serves the desktop/IWER browser client + locally and exposes OOB state at + ``https://:48322/api/oob/v1/state``. Automated validation pairs + this with a desktop Chromium probe on the remote runner, so no + headset, ``adb``, or human click is required. + * - ``python -m isaacteleop.cloudxr --setup-oob`` + - OOB hub + CDP automation: opens the browser on the headset and + auto-clicks CONNECT over USB adb. Client URL is GitHub Pages. + * - ``python -m isaacteleop.cloudxr --setup-oob --host-client`` + - OOB hub + CDP with client at ``/client/`` on the WSS proxy + (air-gapped / proxy use). + * - ``python -m isaacteleop.cloudxr --setup-oob --usb-local`` + - All traffic over USB: adb-reverse + coturn TURN relay + loopback + HTTPS. Requires ``coturn`` and a WiFi-associated headset. + + ``--usb-local`` requires ``--setup-oob``. See + :doc:`/references/oob_teleop_control` for full OOB documentation. + .. list-table:: Environment variables :header-rows: 1 :widths: 25 15 35 25 From 934bdb219694a2598f19b933d95c065919c86b4f Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 11:09:11 -0400 Subject: [PATCH 09/25] Route OOB client config into hosted validation Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 6 +-- deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx | 42 +++++++++++++++++++ scripts/quick_start_client_probe.py | 26 ++++++++++++ scripts/run_quick_start_e2e.sh | 4 ++ 4 files changed, 75 insertions(+), 3 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index fdcc1cdb7..2cf81a35d 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -61,7 +61,7 @@ import { v5 } from 'uuid'; import { CloudXR2DUI, COUNTDOWN_STORAGE_KEY } from './CloudXR2DUI'; import { readUrlParam } from './config/resolve'; import CloudXR3DUI from './CloudXRUI'; -import { HeadsetControlChannel } from '@helpers/controlChannel'; +import { HeadsetControlChannel, type StreamConfig } from '@helpers/controlChannel'; // Performance metrics signals - raw numeric data, one per callback cadence. // Signals update their value without triggering React re-renders. @@ -702,8 +702,8 @@ function App() { const channel = new HeadsetControlChannel({ url: hubWsUrl, token: readUrlParam(p, 'controlToken') ?? undefined, - onConfig: () => { - // Config push handling deferred to phase 2. + onConfig: (streamConfig: StreamConfig) => { + cloudXR2DUI.applyOobStreamConfig(streamConfig); }, getMetricsSnapshot: () => { const snapshots: Array<{ cadence: string; metrics: Record }> = []; diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx index 13619f38e..5dd89d9ca 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx @@ -59,6 +59,7 @@ import { setSelectValueIfAvailable, setupCertificateAcceptanceLink, } from '@helpers/utils'; +import type { StreamConfig } from '@helpers/controlChannel'; import { URL_PARAMS } from './config/params'; import { seedsFromParams } from './config/resolve'; import { @@ -1128,6 +1129,47 @@ export class CloudXR2DUI { return { ...this.currentConfiguration }; } + /** + * Applies stream settings received from the OOB hub without persisting them. + */ + public applyOobStreamConfig(config: StreamConfig): void { + let changed = false; + + if (typeof config.serverIP === 'string' && config.serverIP.trim()) { + const value = config.serverIP.trim(); + if (this.serverIpInput.value !== value) { + this.serverIpInput.value = value; + changed = true; + } + } + + if (typeof config.port === 'number' && Number.isFinite(config.port)) { + const value = String(config.port); + if (this.portInput.value !== value) { + this.portInput.value = value; + changed = true; + } + } + + if (typeof config.codec === 'string') { + const before = this.codecSelect.value; + setSelectValueIfAvailable(this.codecSelect, config.codec); + changed = changed || this.codecSelect.value !== before; + } + + if (typeof config.panelHiddenAtStart === 'boolean') { + const value = config.panelHiddenAtStart ? 'true' : 'false'; + if (this.panelHiddenAtStartSelect.value !== value) { + this.panelHiddenAtStartSelect.value = value; + changed = true; + } + } + + if (changed) { + this.updateConfiguration(); + } + } + /** * Sets the start button state * @param disabled - Whether the button should be disabled diff --git a/scripts/quick_start_client_probe.py b/scripts/quick_start_client_probe.py index 699027c30..d91f1595c 100755 --- a/scripts/quick_start_client_probe.py +++ b/scripts/quick_start_client_probe.py @@ -231,6 +231,32 @@ async def send( "Runtime.evaluate", {"expression": "document.getElementById('startButton')?.click()"}, ) + diagnostic = await send( + ws, + "Runtime.evaluate", + { + "expression": """(() => { + const byId = (id) => document.getElementById(id); + return { + buttonText: byId('startButton')?.textContent?.trim() || '', + buttonDisabled: Boolean(byId('startButton')?.disabled), + errorText: byId('errorMessageText')?.textContent?.trim() || '', + errorClass: byId('errorMessageBox')?.className || '', + serverIP: byId('serverIpInput')?.value || '', + port: byId('portInput')?.value || '', + headless: Boolean(byId('cloudxrHeadless')?.checked), + href: window.location.href, + }; + })()""", + "returnByValue": True, + }, + ) + value = (diagnostic.get("result") or {}).get("result", {}).get("value") + print( + "Page diagnostic after CONNECT: " + + json.dumps(value, sort_keys=True), + flush=True, + ) return if value.get("state") == "failed": raise ProbeError(f"Client capability check failed: {value!r}") diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index 865456492..2f135f143 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -347,6 +347,10 @@ main() { done export NV_CXR_SERVER_PORT="${RUNTIME_PORT}" export PROXY_PORT + if [[ "${VALIDATE_CLIENT}" == "1" ]]; then + export TELEOP_STREAM_SERVER_IP="${TELEOP_STREAM_SERVER_IP:-127.0.0.1}" + export TELEOP_STREAM_PORT="${TELEOP_STREAM_PORT:-${PROXY_PORT}}" + fi log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" local cloudxr_args=( From e6cb4b2a3557611a5c28263493f6b192c4df8c5b Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 12:04:29 -0400 Subject: [PATCH 10/25] Enable software WebGL for Quick Start client probe Signed-off-by: Andrew Russell --- scripts/quick_start_client_probe.py | 7 ++++++- scripts/run_quick_start_e2e.sh | 3 ++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/scripts/quick_start_client_probe.py b/scripts/quick_start_client_probe.py index d91f1595c..5858b0423 100755 --- a/scripts/quick_start_client_probe.py +++ b/scripts/quick_start_client_probe.py @@ -313,8 +313,13 @@ def main() -> int: [ browser, "--headless=new", - "--disable-gpu", "--no-sandbox", + "--disable-dev-shm-usage", + "--ignore-gpu-blocklist", + "--enable-webgl", + "--enable-webgl2", + "--use-gl=swiftshader", + "--enable-unsafe-swiftshader", "--ignore-certificate-errors", "--allow-insecure-localhost", "--remote-debugging-port=0", diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index 2f135f143..af4d6eaf3 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -272,7 +272,7 @@ run_client_probe() { local client_url local state_url - client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72" + client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72&codec=${TELEOP_CLIENT_CODEC}" state_url="https://127.0.0.1:${PROXY_PORT}/api/oob/v1/state" log "Step 5: connecting hosted desktop/IWER client and waiting for OOB streaming state" @@ -350,6 +350,7 @@ main() { if [[ "${VALIDATE_CLIENT}" == "1" ]]; then export TELEOP_STREAM_SERVER_IP="${TELEOP_STREAM_SERVER_IP:-127.0.0.1}" export TELEOP_STREAM_PORT="${TELEOP_STREAM_PORT:-${PROXY_PORT}}" + export TELEOP_CLIENT_CODEC="${TELEOP_CLIENT_CODEC:-h264}" fi log "Using CloudXR runtime port ${NV_CXR_SERVER_PORT} and WSS proxy port ${PROXY_PORT}" From ad9957f4c632471796ad88e2e8e6f622fef47d97 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 12:20:45 -0400 Subject: [PATCH 11/25] Start Quick Start client with CDP user gesture Signed-off-by: Andrew Russell --- scripts/quick_start_client_probe.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/quick_start_client_probe.py b/scripts/quick_start_client_probe.py index 5858b0423..281aeffd2 100755 --- a/scripts/quick_start_client_probe.py +++ b/scripts/quick_start_client_probe.py @@ -229,8 +229,12 @@ async def send( await send( ws, "Runtime.evaluate", - {"expression": "document.getElementById('startButton')?.click()"}, + { + "expression": "document.getElementById('startButton')?.click()", + "userGesture": True, + }, ) + await asyncio.sleep(1.0) diagnostic = await send( ws, "Runtime.evaluate", From 7a9291afcaadae54bd1bd37a861c7cefc0304aed Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 12:51:52 -0400 Subject: [PATCH 12/25] Autostart hosted Quick Start client validation Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 24 +++++++++++++++++++ deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx | 7 ++++++ .../cloudxr/webxr_client/src/config/params.ts | 1 + scripts/run_quick_start_e2e.sh | 2 +- 4 files changed, 33 insertions(+), 1 deletion(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 2cf81a35d..a3ac70fb1 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -123,6 +123,11 @@ function isOobEnabled(searchParams: URLSearchParams): boolean { return v === '1' || v?.toLowerCase() === 'true'; } +function isOobAutoConnectEnabled(searchParams: URLSearchParams): boolean { + const v = readUrlParam(searchParams, 'autoConnect'); + return v === '1' || v?.toLowerCase() === 'true'; +} + function buildOobHubWsUrlFromQuery(searchParams: URLSearchParams): string | null { if (!isOobEnabled(searchParams)) return null; const serverIP = readUrlParam(searchParams, 'serverIP')?.trim(); @@ -162,6 +167,7 @@ function App() { const countdownTimerRef = useRef(null); /** Avoid repeating immersive session dumps on every XR store tick. */ const immersiveSessionDumpLoggedRef = useRef(false); + const autoConnectTriggeredRef = useRef(false); const [countdownDuration, setCountdownDuration] = useState(() => { try { const saved = localStorage.getItem(COUNTDOWN_STORAGE_KEY); @@ -272,6 +278,24 @@ function App() { setCapabilitiesValid(true); cloudXR2DUI.setStartButtonState(false, 'CONNECT'); cloudXR2DUI.updateConnectButtonState(); + + const searchParams = new URLSearchParams(window.location.search); + if ( + isOobEnabled(searchParams) && + isOobAutoConnectEnabled(searchParams) && + cloudXR2DUI.getConfiguration().headless && + !autoConnectTriggeredRef.current + ) { + autoConnectTriggeredRef.current = true; + window.setTimeout(() => { + try { + cloudXR2DUI.requestConnect(); + } catch (error) { + autoConnectTriggeredRef.current = false; + setErrorMessage(`Failed to auto-start XR session: ${error}`); + } + }, 0); + } }; checkCapabilitiesOnce(); diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx index 5dd89d9ca..f9b10008c 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx @@ -1230,6 +1230,13 @@ export class CloudXR2DUI { } } + public requestConnect(): void { + if (!this.handleConnectClick) { + throw new Error('Connect handler is not ready'); + } + this.handleConnectClick(new Event('click')); + } + /** * Shows a status message in the UI with a specific type * @param message - Message to display diff --git a/deps/cloudxr/webxr_client/src/config/params.ts b/deps/cloudxr/webxr_client/src/config/params.ts index e9cd4c6d3..fd19810c2 100644 --- a/deps/cloudxr/webxr_client/src/config/params.ts +++ b/deps/cloudxr/webxr_client/src/config/params.ts @@ -94,5 +94,6 @@ export const URL_PARAMS: UrlParam[] = [ { key: 'turnCredential' }, { key: 'iceRelayOnly' }, { key: 'oobEnable' }, + { key: 'autoConnect' }, { key: 'controlToken' }, ]; diff --git a/scripts/run_quick_start_e2e.sh b/scripts/run_quick_start_e2e.sh index af4d6eaf3..2825f1ea4 100755 --- a/scripts/run_quick_start_e2e.sh +++ b/scripts/run_quick_start_e2e.sh @@ -272,7 +272,7 @@ run_client_probe() { local client_url local state_url - client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72&codec=${TELEOP_CLIENT_CODEC}" + client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&autoConnect=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72&codec=${TELEOP_CLIENT_CODEC}" state_url="https://127.0.0.1:${PROXY_PORT}/api/oob/v1/state" log "Step 5: connecting hosted desktop/IWER client and waiting for OOB streaming state" From 160f79bb7babcd2b57ba0630d51e7602d5cef75a Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 13:19:38 -0400 Subject: [PATCH 13/25] Retry hosted Quick Start client autostart Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 25 +++++++++++++------ deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx | 17 +++++++++---- 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index a3ac70fb1..928277b2f 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -287,14 +287,23 @@ function App() { !autoConnectTriggeredRef.current ) { autoConnectTriggeredRef.current = true; - window.setTimeout(() => { - try { - cloudXR2DUI.requestConnect(); - } catch (error) { - autoConnectTriggeredRef.current = false; - setErrorMessage(`Failed to auto-start XR session: ${error}`); - } - }, 0); + const requestConnect = (attemptsRemaining: number) => { + window.setTimeout(async () => { + try { + setErrorMessage(''); + await cloudXR2DUI.requestConnect(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (attemptsRemaining > 0 && message.includes('not connected to three.js')) { + requestConnect(attemptsRemaining - 1); + return; + } + autoConnectTriggeredRef.current = false; + setErrorMessage(`Failed to auto-start XR session: ${error}`); + } + }, 500); + }; + requestConnect(20); } }; diff --git a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx index f9b10008c..e2d40a101 100644 --- a/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx +++ b/deps/cloudxr/webxr_client/src/CloudXR2DUI.tsx @@ -190,6 +190,8 @@ export class CloudXR2DUI { private onConfigurationChange: ((config: AppConfig) => void) | null = null; /** Connect button click handler for cleanup */ private handleConnectClick: ((event: Event) => void) | null = null; + /** Shared connect action used by button clicks and automated OOB validation. */ + private connectAction: (() => Promise) | null = null; /** Array to store all event listeners for proper cleanup */ private eventListeners: Array<{ element: HTMLElement; @@ -1197,8 +1199,8 @@ export class CloudXR2DUI { this.startButton.removeEventListener('click', this.handleConnectClick); } - // Create new handler - this.handleConnectClick = async () => { + // Create new action + this.connectAction = async () => { this.updateConnectButtonState(); if (this.startButton?.disabled) { this.updateConnectButtonState(); @@ -1222,19 +1224,23 @@ export class CloudXR2DUI { this.setStartButtonState(false, 'CONNECT'); this.updateConnectButtonState(); onError(error as Error); + throw error; } }; + this.handleConnectClick = () => { + void this.connectAction?.().catch(() => undefined); + }; // Add the new listener this.startButton.addEventListener('click', this.handleConnectClick); } } - public requestConnect(): void { - if (!this.handleConnectClick) { + public requestConnect(): Promise { + if (!this.connectAction) { throw new Error('Connect handler is not ready'); } - this.handleConnectClick(new Event('click')); + return this.connectAction(); } /** @@ -1282,6 +1288,7 @@ export class CloudXR2DUI { if (this.startButton && this.handleConnectClick) { this.startButton.removeEventListener('click', this.handleConnectClick); this.handleConnectClick = null; + this.connectAction = null; } // Clean up certificate acceptance link listeners From ad1d5cbbfdbacef0f5762fc6d3c6a166fda9ed7c Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 13:50:35 -0400 Subject: [PATCH 14/25] Use minimal XR features for hosted client validation Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 928277b2f..8979e0056 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -128,6 +128,15 @@ function isOobAutoConnectEnabled(searchParams: URLSearchParams): boolean { return v === '1' || v?.toLowerCase() === 'true'; } +function isOobHeadlessAutoConnect(searchParams: URLSearchParams): boolean { + const headless = readUrlParam(searchParams, 'headless'); + return ( + isOobEnabled(searchParams) && + isOobAutoConnectEnabled(searchParams) && + (headless === '1' || headless?.toLowerCase() === 'true') + ); +} + function buildOobHubWsUrlFromQuery(searchParams: URLSearchParams): string | null { if (!isOobEnabled(searchParams)) return null; const serverIP = readUrlParam(searchParams, 'serverIP')?.trim(); @@ -145,6 +154,10 @@ function App() { const [cloudXR2DUI, setCloudXR2DUI] = useState(null); // IWER loading state const [iwerLoaded, setIwerLoaded] = useState(false); + const oobHeadlessAutoConnect = useMemo( + () => isOobHeadlessAutoConnect(new URLSearchParams(window.location.search)), + [] + ); // Capability state management const [capabilitiesValid, setCapabilitiesValid] = useState(false); const capabilitiesCheckedRef = useRef(false); @@ -344,9 +357,10 @@ function App() { controller: { model: !hideControllerModel, // Allow UI to hide controller models while keeping input active }, - // Request optional WebXR features - use property names, not optionalFeatures array! - handTracking: true, - bodyTracking: true, + // Headless OOB validation runs through desktop/IWER emulation. Keep that request minimal + // so unsupported headset-only features cannot prevent the automated session from starting. + handTracking: !oobHeadlessAutoConnect, + bodyTracking: !oobHeadlessAutoConnect, // Explicitly disable environment/scene feature requests to avoid extra headset prompts. anchors: false, layers: false, @@ -359,7 +373,7 @@ function App() { offerSession: true, }), // hideControllerModel omitted: changing it must not recreate the store or the session would be lost - [xrFoveation, xrFrameBufferScaling] + [xrFoveation, xrFrameBufferScaling, oobHeadlessAutoConnect] ); // Apply controller model visibility when the option changes. store.setController() updates From f815e1738fe10061afb99bf8989aa8f5124cbb47 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 14:19:50 -0400 Subject: [PATCH 15/25] Use local reference space for hosted client validation Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 8979e0056..483e5cb22 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -361,6 +361,9 @@ function App() { // so unsupported headset-only features cannot prevent the automated session from starting. handTracking: !oobHeadlessAutoConnect, bodyTracking: !oobHeadlessAutoConnect, + customSessionInit: oobHeadlessAutoConnect + ? { requiredFeatures: ['local'], optionalFeatures: [] } + : undefined, // Explicitly disable environment/scene feature requests to avoid extra headset prompts. anchors: false, layers: false, From 8cd72a5a04be659d238a3acdbe67f0368676b87e Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 14:51:39 -0400 Subject: [PATCH 16/25] Force IWER for hosted client validation Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 12 ++++++++---- deps/cloudxr/webxr_client/src/App.tsx | 5 +++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index 130843cb9..01661cb5c 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -49,11 +49,11 @@ async function installBundledIWER(): Promise { } } -export async function loadIWERIfNeeded(): Promise { +export async function loadIWERIfNeeded(forceIWER = false): Promise { let supportsImmersive = false; let iwerLoaded = false; - if ('xr' in navigator) { + if (!forceIWER && 'xr' in navigator) { try { const vr = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-vr'); const ar = await (navigator.xr as XRSystem).isSessionSupported?.('immersive-ar'); @@ -61,8 +61,12 @@ export async function loadIWERIfNeeded(): Promise { } catch (_) {} } - if (!supportsImmersive) { - console.info('Immersive mode not supported, installing bundled IWER fallback.'); + if (forceIWER || !supportsImmersive) { + console.info( + forceIWER + ? 'Installing bundled IWER for automated headless validation.' + : 'Immersive mode not supported, installing bundled IWER fallback.' + ); iwerLoaded = await installBundledIWER(); supportsImmersive = iwerLoaded; } diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 483e5cb22..82b289285 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -205,7 +205,8 @@ function App() { // Note: React Three Fiber's emulation is disabled (emulate: false) to avoid conflicts useEffect(() => { const loadIWER = async () => { - const { supportsImmersive, iwerLoaded: wasIwerLoaded } = await loadIWERIfNeeded(); + const { supportsImmersive, iwerLoaded: wasIwerLoaded } = + await loadIWERIfNeeded(oobHeadlessAutoConnect); if (!supportsImmersive) { setErrorMessage('Immersive mode not supported'); setIwerLoaded(false); @@ -222,7 +223,7 @@ function App() { }; loadIWER(); - }, []); + }, [oobHeadlessAutoConnect]); // Update button state when IWER fails and UI becomes ready useEffect(() => { From 88fe376335eb85d3bc476e010827cc8df83e02e6 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 15:23:26 -0400 Subject: [PATCH 17/25] Recreate XR store after IWER loads Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/src/App.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index 82b289285..f678947d9 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -341,7 +341,9 @@ function App() { const hideControllerModel = cloudXR2DUI?.getConfiguration().hideControllerModel ?? false; // XR store must be created after we know which device profile is active. - // useMemo prevents re-creating the store for unrelated UI changes. + // In hosted headless validation, IWER is installed asynchronously after the + // first render. Recreate the store once XR readiness is established so it + // binds to the emulated runtime rather than Chrome's native navigator.xr. const store = useMemo( () => createXRStore({ @@ -377,7 +379,7 @@ function App() { offerSession: true, }), // hideControllerModel omitted: changing it must not recreate the store or the session would be lost - [xrFoveation, xrFrameBufferScaling, oobHeadlessAutoConnect] + [xrFoveation, xrFrameBufferScaling, oobHeadlessAutoConnect, iwerLoaded] ); // Apply controller model visibility when the option changes. store.setController() updates From ffbc322bc600ad8b6081db5748f5583573bd4473 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 15:52:19 -0400 Subject: [PATCH 18/25] Preload IWER before client app bootstrap Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 8 +++++ deps/cloudxr/webxr_client/src/App.tsx | 33 ++++++++++------- deps/cloudxr/webxr_client/src/index.tsx | 36 +++++++++++++++++-- 3 files changed, 61 insertions(+), 16 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index 01661cb5c..c327900f4 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -30,6 +30,10 @@ export interface IWERLoadResult { async function installBundledIWER(): Promise { try { + if (window.xrDevice) { + return true; + } + const IWERModule = await import('iwer'); const IWERGlobal = (IWERModule as any).default ?? IWERModule; const XRDeviceCtor = IWERGlobal.XRDevice; @@ -50,6 +54,10 @@ async function installBundledIWER(): Promise { } export async function loadIWERIfNeeded(forceIWER = false): Promise { + if (forceIWER && window.xrDevice) { + return { supportsImmersive: true, iwerLoaded: true }; + } + let supportsImmersive = false; let iwerLoaded = false; diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index f678947d9..c425f3546 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -153,7 +153,9 @@ function App() { // 2D UI management const [cloudXR2DUI, setCloudXR2DUI] = useState(null); // IWER loading state - const [iwerLoaded, setIwerLoaded] = useState(false); + const [iwerLoaded, setIwerLoaded] = useState( + () => sessionStorage.getItem('iwerPreloaded') === 'true' + ); const oobHeadlessAutoConnect = useMemo( () => isOobHeadlessAutoConnect(new URLSearchParams(window.location.search)), [] @@ -205,20 +207,25 @@ function App() { // Note: React Three Fiber's emulation is disabled (emulate: false) to avoid conflicts useEffect(() => { const loadIWER = async () => { - const { supportsImmersive, iwerLoaded: wasIwerLoaded } = - await loadIWERIfNeeded(oobHeadlessAutoConnect); - if (!supportsImmersive) { - setErrorMessage('Immersive mode not supported'); - setIwerLoaded(false); - setCapabilitiesValid(false); - capabilitiesCheckedRef.current = false; // Reset check flag on failure - return; - } - // IWER loaded successfully, now we can proceed with capability checks + if (oobHeadlessAutoConnect && sessionStorage.getItem('iwerPreloaded') === 'true') { setIwerLoaded(true); + return; + } + + const { supportsImmersive, iwerLoaded: wasIwerLoaded } = + await loadIWERIfNeeded(oobHeadlessAutoConnect); + if (!supportsImmersive) { + setErrorMessage('Immersive mode not supported'); + setIwerLoaded(false); + setCapabilitiesValid(false); + capabilitiesCheckedRef.current = false; // Reset check flag on failure + return; + } + // IWER loaded successfully, now we can proceed with capability checks + setIwerLoaded(true); // Store whether IWER was loaded for status message display later - if (wasIwerLoaded) { - sessionStorage.setItem('iwerWasLoaded', 'true'); + if (wasIwerLoaded) { + sessionStorage.setItem('iwerWasLoaded', 'true'); } }; diff --git a/deps/cloudxr/webxr_client/src/index.tsx b/deps/cloudxr/webxr_client/src/index.tsx index 6072847ef..f233e8631 100644 --- a/deps/cloudxr/webxr_client/src/index.tsx +++ b/deps/cloudxr/webxr_client/src/index.tsx @@ -18,14 +18,44 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import App from './App'; +import { loadIWERIfNeeded } from '@helpers/LoadIWER'; import { mountBuildInfoOverlayIfRequested } from './BuildInfoOverlay'; +function isEnabled(value: string | null): boolean { + return value === '1' || value?.toLowerCase() === 'true'; +} + +function shouldPreloadIWERForHeadlessOob(): boolean { + const params = new URLSearchParams(window.location.search); + return ( + isEnabled(params.get('oobEnable')) && + isEnabled(params.get('autoConnect')) && + isEnabled(params.get('headless')) + ); +} + +async function preloadIWERForHeadlessOob() { + if (!shouldPreloadIWERForHeadlessOob()) { + return; + } + + const { supportsImmersive, iwerLoaded } = await loadIWERIfNeeded(true); + if (supportsImmersive && iwerLoaded) { + sessionStorage.setItem('iwerWasLoaded', 'true'); + sessionStorage.setItem('iwerPreloaded', 'true'); + return; + } + + sessionStorage.removeItem('iwerPreloaded'); +} + // Start the React app immediately in the 3d-ui container -function startApp() { +async function startApp() { const reactContainer = document.getElementById('3d-ui'); if (reactContainer) { + await preloadIWERForHeadlessOob(); + const { default: App } = await import('./App'); const root = ReactDOM.createRoot(reactContainer); root.render( @@ -43,5 +73,5 @@ function startApp() { if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', startApp); } else { - startApp(); + void startApp(); } From 8bb3752559551ee00afb42837efb26f228a95a8e Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Thu, 16 Jul 2026 16:25:59 -0400 Subject: [PATCH 19/25] Harden hosted IWER session diagnostics Signed-off-by: Andrew Russell --- deps/cloudxr/webxr_client/helpers/LoadIWER.ts | 95 ++++++++++++++++++- deps/cloudxr/webxr_client/src/App.tsx | 2 +- scripts/quick_start_client_probe.py | 70 ++++++++++++-- 3 files changed, 154 insertions(+), 13 deletions(-) diff --git a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts index c327900f4..1804f9762 100644 --- a/deps/cloudxr/webxr_client/helpers/LoadIWER.ts +++ b/deps/cloudxr/webxr_client/helpers/LoadIWER.ts @@ -30,12 +30,15 @@ export interface IWERLoadResult { async function installBundledIWER(): Promise { try { + const IWERModule = await import('iwer'); + const IWERGlobal = (IWERModule as any).default ?? IWERModule; if (window.xrDevice) { + ensureRuntimeInstalled(window.xrDevice, IWERGlobal); + installNavigatorXRShim(window.xrDevice, IWERGlobal); + installMakeXRCompatibleShim(); return true; } - const IWERModule = await import('iwer'); - const IWERGlobal = (IWERModule as any).default ?? IWERModule; const XRDeviceCtor = IWERGlobal.XRDevice; const deviceProfile = IWERGlobal.metaQuest3; if (!XRDeviceCtor || !deviceProfile) { @@ -44,8 +47,22 @@ async function installBundledIWER(): Promise { } const device: XRDevice = new XRDeviceCtor(deviceProfile); - await device.installRuntime(); + ensureRuntimeInstalled(device, IWERGlobal); + installNavigatorXRShim(device, IWERGlobal); + installMakeXRCompatibleShim(); window.xrDevice = device; + const supportsVr = await (navigator.xr as XRSystem | undefined) + ?.isSessionSupported?.('immersive-vr') + .catch(() => false); + console.info('Bundled IWER runtime installed.', { + navigatorXR: navigator.xr?.constructor?.name ?? '', + requestSession: String((navigator.xr as XRSystem | undefined)?.requestSession).slice( + 0, + 80 + ), + supportsImmersiveVr: Boolean(supportsVr), + supportedSessionModes: readSupportedSessionModes(device, IWERGlobal), + }); return true; } catch (e) { console.warn('IWER runtime install failed:', e); @@ -53,6 +70,78 @@ async function installBundledIWER(): Promise { } } +function ensureRuntimeInstalled(device: XRDevice, IWERGlobal: any): void { + const privateDevice = readPrivateDevice(device, IWERGlobal); + if (privateDevice?.xrSystem && navigator.xr === privateDevice.xrSystem) { + return; + } + + (device as any).installRuntime?.({ forceInstall: true }); +} + +function readPrivateDevice(device: XRDevice, IWERGlobal: any): any { + try { + return IWERGlobal.P_DEVICE ? (device as any)[IWERGlobal.P_DEVICE] : undefined; + } catch (_) { + return undefined; + } +} + +function readSupportedSessionModes(device: XRDevice, IWERGlobal: any): string[] { + try { + const privateDevice = readPrivateDevice(device, IWERGlobal); + const modes = (device as any).supportedSessionModes ?? privateDevice?.supportedSessionModes; + return Array.isArray(modes) ? modes : []; + } catch (_) { + return []; + } +} + +function installNavigatorXRShim(device: XRDevice, IWERGlobal: any): void { + const xrSystem = (() => { + try { + const privateDevice = readPrivateDevice(device, IWERGlobal); + return privateDevice?.xrSystem ?? (navigator.xr as XRSystem | undefined); + } catch (_) { + return navigator.xr as XRSystem | undefined; + } + })(); + if (!xrSystem) { + return; + } + + const defineXR = (target: object | undefined) => { + if (!target) { + return; + } + try { + Object.defineProperty(target, 'xr', { + value: xrSystem, + configurable: true, + }); + } catch (_) {} + }; + + defineXR(window.navigator); + defineXR(Object.getPrototypeOf(window.navigator)); +} + +function installMakeXRCompatibleShim(): void { + const install = (ctor: unknown) => { + const prototype = (ctor as { prototype?: Record } | undefined)?.prototype; + if (!prototype) { + return; + } + Object.defineProperty(prototype, 'makeXRCompatible', { + value: () => Promise.resolve(), + configurable: true, + }); + }; + + install((window as any).WebGLRenderingContext); + install((window as any).WebGL2RenderingContext); +} + export async function loadIWERIfNeeded(forceIWER = false): Promise { if (forceIWER && window.xrDevice) { return { supportsImmersive: true, iwerLoaded: true }; diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index c425f3546..df0b04766 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -372,7 +372,7 @@ function App() { handTracking: !oobHeadlessAutoConnect, bodyTracking: !oobHeadlessAutoConnect, customSessionInit: oobHeadlessAutoConnect - ? { requiredFeatures: ['local'], optionalFeatures: [] } + ? { requiredFeatures: ['local-floor'], optionalFeatures: [] } : undefined, // Explicitly disable environment/scene feature requests to avoid extra headset prompts. anchors: false, diff --git a/scripts/quick_start_client_probe.py b/scripts/quick_start_client_probe.py index 281aeffd2..dd4e9e6c8 100755 --- a/scripts/quick_start_client_probe.py +++ b/scripts/quick_start_client_probe.py @@ -144,6 +144,37 @@ async def cdp_click_connect(ws_url: str, timeout: float) -> None: from websockets.asyncio.client import connect as ws_connect next_id = 0 + browser_events: list[str] = [] + + def record_event(msg: dict[str, Any]) -> None: + method = msg.get("method") + params = msg.get("params") or {} + entry: str | None = None + if method == "Runtime.consoleAPICalled": + args = [] + for arg in params.get("args", []): + value = arg.get("value") + if value is None: + value = arg.get("description") + args.append(str(value)) + entry = f"console.{params.get('type', 'log')}: " + " ".join(args) + elif method == "Runtime.exceptionThrown": + details = params.get("exceptionDetails") or {} + entry = ( + "exception: " + + str(details.get("text") or "") + + " " + + str((details.get("exception") or {}).get("description") or "") + ) + elif method == "Log.entryAdded": + log_entry = params.get("entry") or {} + entry = f"log.{log_entry.get('level', 'info')}: " + str( + log_entry.get("text") or "" + ) + + if entry: + browser_events.append(entry[:1200]) + del browser_events[:-25] async def send( ws, method: str, params: dict[str, Any] | None = None @@ -156,6 +187,9 @@ async def send( while True: raw = await ws.recv() msg = json.loads(raw) + if "id" not in msg: + record_event(msg) + continue if msg.get("id") == next_id: if "error" in msg: raise ProbeError(f"CDP {method} failed: {msg['error']}") @@ -165,6 +199,7 @@ async def send( for method, params in ( ("Page.enable", None), ("Runtime.enable", None), + ("Log.enable", None), ("Security.enable", None), ("Security.setIgnoreCertificateErrors", {"ignore": True}), ): @@ -226,21 +261,22 @@ async def send( "clickCount": 1, }, ) - await send( - ws, - "Runtime.evaluate", - { - "expression": "document.getElementById('startButton')?.click()", - "userGesture": True, - }, - ) await asyncio.sleep(1.0) diagnostic = await send( ws, "Runtime.evaluate", { - "expression": """(() => { + "expression": """(async () => { const byId = (id) => document.getElementById(id); + const stringifyFn = (fn) => { + try { return String(fn).slice(0, 240); } catch (_) { return ''; } + }; + let supportsImmersiveVr = null; + try { + supportsImmersiveVr = await navigator.xr?.isSessionSupported?.('immersive-vr'); + } catch (error) { + supportsImmersiveVr = `error: ${error}`; + } return { buttonText: byId('startButton')?.textContent?.trim() || '', buttonDisabled: Boolean(byId('startButton')?.disabled), @@ -250,8 +286,18 @@ async def send( port: byId('portInput')?.value || '', headless: Boolean(byId('cloudxrHeadless')?.checked), href: window.location.href, + navigatorXR: navigator.xr?.constructor?.name || '', + xrDevice: window.xrDevice?.constructor?.name || '', + xrDeviceSupportedSessionModes: window.xrDevice?.supportedSessionModes || [], + navigatorOwnXR: Object.hasOwn(window.navigator, 'xr'), + navigatorPrototypeXR: Boolean(Object.getOwnPropertyDescriptor(Object.getPrototypeOf(window.navigator), 'xr')), + supportsImmersiveVr, + requestSession: stringifyFn(navigator.xr?.requestSession), + webglMakeXRCompatible: stringifyFn(window.WebGLRenderingContext?.prototype?.makeXRCompatible), + webgl2MakeXRCompatible: stringifyFn(window.WebGL2RenderingContext?.prototype?.makeXRCompatible), }; })()""", + "awaitPromise": True, "returnByValue": True, }, ) @@ -261,6 +307,12 @@ async def send( + json.dumps(value, sort_keys=True), flush=True, ) + if browser_events: + print( + "Recent browser events after CONNECT: " + + json.dumps(browser_events[-10:], sort_keys=True), + flush=True, + ) return if value.get("state") == "failed": raise ProbeError(f"Client capability check failed: {value!r}") From ef34e22ba90c82e35a65a7a55098c21a906ac8f3 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:02:27 -0400 Subject: [PATCH 20/25] ci: add Quick Start Isaac Lab validation hook --- .github/workflows/build-ubuntu.yml | 55 ++++ scripts/run_quick_start_isaac_lab_e2e.sh | 344 +++++++++++++++++++++++ 2 files changed, 399 insertions(+) create mode 100755 scripts/run_quick_start_isaac_lab_e2e.sh diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index de371fed0..bc2333fa7 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -625,6 +625,61 @@ jobs: if-no-files-found: warn retention-days: 7 + test-quick-start-isaac-lab-e2e: + # Optional Quick Start next-step validation: use Isaac Lab as the + # application under test. This runs only on runners where the repo variable + # QUICK_START_ISAAC_LAB_ROOT points at a provisioned Isaac Lab checkout. + if: ${{ vars.QUICK_START_ISAAC_LAB_ROOT != '' }} + runs-on: [self-hosted, linux, gpu, x64] + needs: + - build-ubuntu + - test-quick-start-client-e2e + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: '3.11' + + - name: Download isaacteleop wheel (x64, py3.11) + uses: actions/download-artifact@v7 + with: + name: isaacteleop-wheels-x64-py3.11 + path: quick-start-wheels + + - name: Build hosted desktop web client + uses: ./.github/actions/build-cloudxr-web-client + with: + ngc-api-key: ${{ secrets.NGC_TELEOP_CORE_GITHUB_SERVICE_KEY }} + + - name: Run Quick Start Isaac Lab E2E workflow + env: + CI: true + QUICK_START_ISAAC_LAB_ROOT: ${{ vars.QUICK_START_ISAAC_LAB_ROOT }} + QUICK_START_ISAAC_LAB_WHEEL_DIR: quick-start-wheels + QUICK_START_ISAAC_LAB_ARTIFACT_DIR: ${{ runner.temp }}/isaacteleop-quick-start-isaac-lab-e2e + TELEOP_WEB_CLIENT_STATIC_DIR: ${{ github.workspace }}/deps/cloudxr/webxr_client/build + run: ./scripts/run_quick_start_isaac_lab_e2e.sh + + - name: Upload Quick Start Isaac Lab E2E artifacts + if: ${{ always() }} + uses: actions/upload-artifact@v6 + with: + name: quick-start-isaac-lab-e2e-x64-py3.11 + path: | + ${{ runner.temp }}/isaacteleop-quick-start-isaac-lab-e2e/*.log + ${{ runner.temp }}/isaacteleop-quick-start-isaac-lab-e2e/*.json + ${{ runner.temp }}/isaacteleop-quick-start-isaac-lab-e2e/cloudxr/run/cloudxr.env + ${{ runner.temp }}/isaacteleop-quick-start-isaac-lab-e2e/cloudxr/logs/**/*.log + if-no-files-found: warn + retention-days: 7 + test-teleop-ros2: runs-on: [self-hosted, linux, gpu, "${{ matrix.arch }}"] needs: build-ubuntu diff --git a/scripts/run_quick_start_isaac_lab_e2e.sh b/scripts/run_quick_start_isaac_lab_e2e.sh new file mode 100755 index 000000000..5935e7534 --- /dev/null +++ b/scripts/run_quick_start_isaac_lab_e2e.sh @@ -0,0 +1,344 @@ +#!/usr/bin/env bash +# +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Validate the Quick Start "Teleoperation in Isaac Lab" next step against a +# pre-provisioned Isaac Lab checkout. The script installs the candidate +# isaacteleop wheel into that Isaac Lab environment, launches CloudXR with the +# hosted no-headset client path, then runs Isaac Lab's XR teleop app under a +# bounded timeout. + +set -euo pipefail + +ROOT_DIR=$(git rev-parse --show-toplevel) +GUIDE_PATH="docs/source/getting_started/quick_start.rst" +ISAAC_LAB_ROOT="${QUICK_START_ISAAC_LAB_ROOT:-${ISAAC_LAB_ROOT:-}}" +ARTIFACT_DIR="${QUICK_START_ISAAC_LAB_ARTIFACT_DIR:-${RUNNER_TEMP:-/tmp}/isaacteleop-quick-start-isaac-lab-e2e}" +CLOUDXR_INSTALL_DIR="${QUICK_START_ISAAC_LAB_CLOUDXR_INSTALL_DIR:-${ARTIFACT_DIR}/cloudxr}" +WHEEL_DIR="${QUICK_START_ISAAC_LAB_WHEEL_DIR:-${ROOT_DIR}/install/wheels}" +PIP_EXTRA_INDEX_URL="${QUICK_START_ISAAC_LAB_PIP_EXTRA_INDEX_URL:-https://pypi.nvidia.com}" +CLOUDXR_READY_TIMEOUT_SEC="${QUICK_START_ISAAC_LAB_CLOUDXR_READY_TIMEOUT_SEC:-180}" +CLIENT_PROBE_TIMEOUT_SEC="${QUICK_START_ISAAC_LAB_CLIENT_PROBE_TIMEOUT_SEC:-90}" +ISAAC_LAB_TIMEOUT_SEC="${QUICK_START_ISAAC_LAB_TIMEOUT_SEC:-180}" +DEFAULT_CLOUDXR_SERVER_PORT=49100 +DEFAULT_WSS_PROXY_PORT=48322 +RUNTIME_PORT="${QUICK_START_ISAAC_LAB_RUNTIME_PORT:-}" +PROXY_PORT="${QUICK_START_ISAAC_LAB_WSS_PROXY_PORT:-}" +TELEOP_CLIENT_CODEC="${TELEOP_CLIENT_CODEC:-h264}" + +CLOUDXR_LOG="${ARTIFACT_DIR}/cloudxr-server.log" +CLIENT_PROBE_LOG="${ARTIFACT_DIR}/client-probe.log" +CLIENT_STATE_JSON="${ARTIFACT_DIR}/client-oob-state.json" +ISAAC_LAB_LOG="${ARTIFACT_DIR}/isaac-lab-xr-teleop.log" +SUMMARY_JSON="${ARTIFACT_DIR}/quick-start-isaac-lab-e2e-summary.json" + +cloudxr_pid="" + +log() { + printf '[quick-start-isaac-lab-e2e] %s\n' "$*" +} + +json_escape() { + python3 - "$1" <<'PY' +import json +import sys + +print(json.dumps(sys.argv[1])) +PY +} + +write_summary() { + local status="$1" + local message="$2" + mkdir -p "${ARTIFACT_DIR}" + cat > "${SUMMARY_JSON}" </dev/null; then + log "Stopping CloudXR server (pid=${cloudxr_pid})" + kill "${cloudxr_pid}" 2>/dev/null || true + wait "${cloudxr_pid}" 2>/dev/null || true + fi +} +trap cleanup EXIT + +require_isaac_lab() { + if [[ -z "${ISAAC_LAB_ROOT}" ]]; then + fail "set QUICK_START_ISAAC_LAB_ROOT or ISAAC_LAB_ROOT to a provisioned Isaac Lab checkout" + fi + if [[ ! -x "${ISAAC_LAB_ROOT}/isaaclab.sh" ]]; then + fail "Isaac Lab launcher not found or not executable: ${ISAAC_LAB_ROOT}/isaaclab.sh" + fi + if [[ ! -f "${ISAAC_LAB_ROOT}/scripts/environments/teleoperation/teleop_se3_agent.py" ]]; then + fail "Isaac Lab teleop_se3_agent.py was not found under ${ISAAC_LAB_ROOT}" + fi +} + +resolve_wheel() { + shopt -s nullglob + local wheels=("${WHEEL_DIR}"/isaacteleop-*.whl) + shopt -u nullglob + + if (( ${#wheels[@]} == 0 )); then + return 1 + fi + if (( ${#wheels[@]} > 1 )); then + printf 'Expected one wheel in %s, found %d\n' "${WHEEL_DIR}" "${#wheels[@]}" >&2 + return 2 + fi + + printf '%s\n' "${wheels[0]}" +} + +isaaclab_python() { + (cd "${ISAAC_LAB_ROOT}" && ./isaaclab.sh -p "$@") +} + +install_candidate_wheel() { + if [[ "${QUICK_START_ISAAC_LAB_SKIP_INSTALL:-0}" == "1" ]]; then + log "Skipping candidate wheel install because QUICK_START_ISAAC_LAB_SKIP_INSTALL=1" + return 0 + fi + + local wheel + local resolve_rc=0 + wheel=$(resolve_wheel) || resolve_rc=$? + if (( resolve_rc != 0 )); then + fail "could not resolve exactly one isaacteleop wheel from ${WHEEL_DIR}" + fi + + local wheel_name + local wheel_version + wheel_name=$(basename "${wheel}") + wheel_version=$(sed -E 's/^isaacteleop-([^-]+)-.*/\1/' <<< "${wheel_name}" | tr '_' '-') + + log "Installing candidate wheel ${wheel_name} into Isaac Lab" + isaaclab_python -m pip install --no-cache-dir "${wheel}" + isaaclab_python -m pip install \ + --no-cache-dir \ + --extra-index-url "${PIP_EXTRA_INDEX_URL}" \ + "isaacteleop[cloudxr,retargeters]==${wheel_version}" +} + +port_is_available() { + python3 - "$1" <<'PY' +import socket +import sys + +port = int(sys.argv[1]) +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + try: + sock.bind(("", port)) + except OSError: + raise SystemExit(1) from None +PY +} + +pick_free_port() { + python3 - <<'PY' +import socket + +with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("", 0)) + print(sock.getsockname()[1]) +PY +} + +resolve_port() { + local requested="$1" + local default_port="$2" + + if [[ -n "${requested}" ]]; then + port_is_available "${requested}" || return $? + printf '%s\n' "${requested}" + return 0 + fi + + if [[ "${CI:-}" == "true" ]]; then + pick_free_port + return 0 + fi + + if port_is_available "${default_port}"; then + printf '%s\n' "${default_port}" + return 0 + fi + + pick_free_port +} + +wait_for_file() { + local path="$1" + local timeout="$2" + local deadline=$((SECONDS + timeout)) + + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if [[ -f "${path}" ]]; then + return 0 + fi + if [[ -n "${cloudxr_pid}" ]] && ! kill -0 "${cloudxr_pid}" 2>/dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +wait_for_log_line() { + local pattern="$1" + local timeout="$2" + local deadline=$((SECONDS + timeout)) + + while [[ "${SECONDS}" -lt "${deadline}" ]]; do + if [[ -f "${CLOUDXR_LOG}" ]] && grep -qE "${pattern}" "${CLOUDXR_LOG}"; then + return 0 + fi + if [[ -n "${cloudxr_pid}" ]] && ! kill -0 "${cloudxr_pid}" 2>/dev/null; then + return 1 + fi + sleep 1 + done + + return 1 +} + +assert_clean_log() { + local label="$1" + local file="$2" + if [[ ! -s "${file}" ]]; then + fail "${label} did not produce a log" + fi + if grep -Eiq 'Traceback|RuntimeError|Segmentation fault|Aborted|failed to start|exited unexpectedly' "${file}"; then + log "===== ${label} =====" + cat "${file}" || true + fail "${label} contains a fatal error" + fi +} + +run_client_probe() { + local client_url="https://127.0.0.1:${PROXY_PORT}/client/?oobEnable=1&autoConnect=1&serverIP=127.0.0.1&port=${PROXY_PORT}&headless=true&autoRefreshMode=never&deviceFrameRate=72&codec=${TELEOP_CLIENT_CODEC}" + local state_url="https://127.0.0.1:${PROXY_PORT}/api/oob/v1/state" + local probe_rc=0 + + log "Connecting hosted desktop/IWER client" + isaaclab_python "${ROOT_DIR}/scripts/quick_start_client_probe.py" \ + --client-url "${client_url}" \ + --state-url "${state_url}" \ + --timeout "${CLIENT_PROBE_TIMEOUT_SEC}" \ + --summary-json "${CLIENT_STATE_JSON}" \ + > "${CLIENT_PROBE_LOG}" 2>&1 || probe_rc=$? + + if (( probe_rc != 0 )); then + log "===== client probe log =====" + cat "${CLIENT_PROBE_LOG}" || true + fail "desktop/IWER client probe exited with status ${probe_rc}" + fi +} + +run_isaac_lab_teleop() { + local env_file="$1" + local app_rc=0 + + log "Running Isaac Lab XR teleop app under ${ISAAC_LAB_TIMEOUT_SEC}s timeout" + ( + set -euo pipefail + cd "${ISAAC_LAB_ROOT}" + # shellcheck disable=SC1090 + source "${env_file}" + timeout "${ISAAC_LAB_TIMEOUT_SEC}" ./isaaclab.sh -p \ + scripts/environments/teleoperation/teleop_se3_agent.py \ + --task IsaacContrib-Stack-Cube-Franka-IK-Abs \ + --viz kit \ + --num_envs 1 \ + --xr + ) > "${ISAAC_LAB_LOG}" 2>&1 || app_rc=$? + + if (( app_rc != 0 && app_rc != 124 )); then + log "===== Isaac Lab teleop log =====" + cat "${ISAAC_LAB_LOG}" || true + fail "Isaac Lab XR teleop app exited with status ${app_rc}" + fi + + assert_clean_log "Isaac Lab teleop log" "${ISAAC_LAB_LOG}" + log "Isaac Lab XR teleop app reached bounded runtime without fatal errors" +} + +main() { + rm -rf "${ARTIFACT_DIR}" + mkdir -p "${ARTIFACT_DIR}" + + require_isaac_lab + test -f "${ROOT_DIR}/${GUIDE_PATH}" || fail "Quick Start guide not found at ${GUIDE_PATH}" + install_candidate_wheel + + RUNTIME_PORT=$(resolve_port "${RUNTIME_PORT}" "${DEFAULT_CLOUDXR_SERVER_PORT}") || \ + fail "requested CloudXR runtime port ${RUNTIME_PORT} is not available" + PROXY_PORT=$(resolve_port "${PROXY_PORT}" "${DEFAULT_WSS_PROXY_PORT}") || \ + fail "requested WSS proxy port ${PROXY_PORT} is not available" + while [[ "${PROXY_PORT}" == "${RUNTIME_PORT}" ]]; do + PROXY_PORT=$(pick_free_port) + done + export NV_CXR_SERVER_PORT="${RUNTIME_PORT}" + export PROXY_PORT + export TELEOP_STREAM_SERVER_IP="${TELEOP_STREAM_SERVER_IP:-127.0.0.1}" + export TELEOP_STREAM_PORT="${TELEOP_STREAM_PORT:-${PROXY_PORT}}" + export TELEOP_CLIENT_CODEC + + log "Starting CloudXR server with hosted client/OOB hub" + PYTHONUNBUFFERED=1 isaaclab_python -u -m isaacteleop.cloudxr \ + --cloudxr-install-dir "${CLOUDXR_INSTALL_DIR}" \ + --accept-eula \ + --host-client \ + --enable-oob-hub \ + > "${CLOUDXR_LOG}" 2>&1 & + cloudxr_pid=$! + + local env_file="${CLOUDXR_INSTALL_DIR}/run/cloudxr.env" + wait_for_file "${env_file}" "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR did not write ${env_file}" + } + wait_for_log_line 'CloudXR runtime:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR runtime did not report running" + } + wait_for_log_line 'CloudXR WSS proxy:.*running' "${CLOUDXR_READY_TIMEOUT_SEC}" || { + log "===== CloudXR server log =====" + cat "${CLOUDXR_LOG}" || true + fail "CloudXR WSS proxy did not report running" + } + assert_clean_log "CloudXR server log" "${CLOUDXR_LOG}" + + run_client_probe + run_isaac_lab_teleop "${env_file}" + + write_summary "passed" "Isaac Lab XR teleop app launched with hosted CloudXR client state" + log "Quick Start Isaac Lab E2E workflow completed" +} + +main "$@" From 12706a0d4e2ff6c5348d498ababc9e94ac0f3dce Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:32:00 -0400 Subject: [PATCH 21/25] style: format Quick Start launcher tests Signed-off-by: Andrew Russell --- src/core/cloudxr_tests/python/test_launcher.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index 901eb402a..32254576a 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -216,6 +216,7 @@ def test_construction_accepts_oob_hub_without_adb_setup(self, tmp_path): assert launcher._enable_oob_hub is True assert launcher._setup_oob is False + # ============================================================================ # TestLauncherStop # ============================================================================ From fecbfb2d5a770cf90be29a02a670edaed419014c Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Fri, 17 Jul 2026 22:55:11 -0400 Subject: [PATCH 22/25] test: align launcher WSS error wording --- src/core/cloudxr/python/launcher.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index 3d242b9c7..16cabaa60 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -147,7 +147,7 @@ def __init__( raise ValueError( "start_wss_proxy=False is incompatible with enable_oob_hub, " "setup_oob, usb_local, and host_client " - "(those features require the WSS proxy)" + "(each requires the WSS proxy)" ) if self._usb_local or self._host_client: From c912e2543e85e21239e67fef8a567091153c870b Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Sat, 18 Jul 2026 01:40:36 -0400 Subject: [PATCH 23/25] ci: limit Ubuntu workflow token permissions --- .github/workflows/build-ubuntu.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/build-ubuntu.yml b/.github/workflows/build-ubuntu.yml index bc2333fa7..c6812d591 100644 --- a/.github/workflows/build-ubuntu.yml +++ b/.github/workflows/build-ubuntu.yml @@ -15,6 +15,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} +permissions: + contents: read + env: # Shared apt build dependencies. Each job installs these plus its own extras # (ccache, clang-format, the OAK camera autotools chain). From 23eef1d78fef7dd5684bc901ebbf56e8918624a9 Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 17:40:03 -0400 Subject: [PATCH 24/25] Fix Quick Start review findings --- deps/cloudxr/webxr_client/src/App.tsx | 85 +++++++++++++------ deps/cloudxr/webxr_client/src/index.tsx | 7 +- src/core/cloudxr/python/launcher.py | 26 +++++- .../cloudxr_tests/python/test_launcher.py | 19 ++++- 4 files changed, 102 insertions(+), 35 deletions(-) diff --git a/deps/cloudxr/webxr_client/src/App.tsx b/deps/cloudxr/webxr_client/src/App.tsx index c809c2ed9..26e1acab4 100644 --- a/deps/cloudxr/webxr_client/src/App.tsx +++ b/deps/cloudxr/webxr_client/src/App.tsx @@ -187,6 +187,7 @@ function AppContent() { /** Avoid repeating immersive session dumps on every XR store tick. */ const immersiveSessionDumpLoggedRef = useRef(false); const autoConnectTriggeredRef = useRef(false); + const autoConnectUiRef = useRef(null); const [countdownDuration, setCountdownDuration] = useState(() => { try { const saved = localStorage.getItem(COUNTDOWN_STORAGE_KEY); @@ -303,38 +304,68 @@ function AppContent() { setCapabilitiesValid(true); cloudXR2DUI.setStartButtonState(false, 'CONNECT'); cloudXR2DUI.updateConnectButtonState(); - - const searchParams = new URLSearchParams(window.location.search); - if ( - isOobEnabled(searchParams) && - isOobAutoConnectEnabled(searchParams) && - cloudXR2DUI.getConfiguration().headless && - !autoConnectTriggeredRef.current - ) { - autoConnectTriggeredRef.current = true; - const requestConnect = (attemptsRemaining: number) => { - window.setTimeout(async () => { - try { - setErrorMessage(''); - await cloudXR2DUI.requestConnect(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (attemptsRemaining > 0 && message.includes('not connected to three.js')) { - requestConnect(attemptsRemaining - 1); - return; - } - autoConnectTriggeredRef.current = false; - setErrorMessage(`Failed to auto-start XR session: ${error}`); - } - }, 500); - }; - requestConnect(20); - } }; checkCapabilitiesOnce(); }, [cloudXR2DUI, iwerLoaded]); + // Store recreation after IWER loads also recreates CloudXR2DUI. Keep auto-connect + // tied to the current UI instance so retries cannot target an object cleaned up by + // the previous store's effect teardown. + useEffect(() => { + if (autoConnectUiRef.current !== cloudXR2DUI) { + autoConnectUiRef.current = cloudXR2DUI; + autoConnectTriggeredRef.current = false; + } + + if (!cloudXR2DUI || !iwerLoaded || !capabilitiesValid) { + return; + } + + const searchParams = new URLSearchParams(window.location.search); + if ( + !isOobEnabled(searchParams) || + !isOobAutoConnectEnabled(searchParams) || + !cloudXR2DUI.getConfiguration().headless || + autoConnectTriggeredRef.current + ) { + return; + } + + let cancelled = false; + autoConnectTriggeredRef.current = true; + const requestConnect = (attemptsRemaining: number) => { + window.setTimeout(async () => { + if (cancelled) { + return; + } + try { + setErrorMessage(''); + await cloudXR2DUI.requestConnect(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const retryable = + message.includes('not connected to three.js') || + message.includes('Connect handler is not ready'); + if (attemptsRemaining > 0 && retryable) { + requestConnect(attemptsRemaining - 1); + return; + } + autoConnectTriggeredRef.current = false; + setErrorMessage(`Failed to auto-start XR session: ${error}`); + } + }, 500); + }; + requestConnect(20); + + return () => { + cancelled = true; + if (autoConnectUiRef.current === cloudXR2DUI) { + autoConnectTriggeredRef.current = false; + } + }; + }, [cloudXR2DUI, iwerLoaded, capabilitiesValid]); + // Track config changes to trigger re-renders when form values change const [configVersion, setConfigVersion] = useState(0); diff --git a/deps/cloudxr/webxr_client/src/index.tsx b/deps/cloudxr/webxr_client/src/index.tsx index f233e8631..44e264866 100644 --- a/deps/cloudxr/webxr_client/src/index.tsx +++ b/deps/cloudxr/webxr_client/src/index.tsx @@ -20,6 +20,7 @@ import ReactDOM from 'react-dom/client'; import { loadIWERIfNeeded } from '@helpers/LoadIWER'; import { mountBuildInfoOverlayIfRequested } from './BuildInfoOverlay'; +import { readUrlParam } from './config/resolve'; function isEnabled(value: string | null): boolean { return value === '1' || value?.toLowerCase() === 'true'; @@ -28,9 +29,9 @@ function isEnabled(value: string | null): boolean { function shouldPreloadIWERForHeadlessOob(): boolean { const params = new URLSearchParams(window.location.search); return ( - isEnabled(params.get('oobEnable')) && - isEnabled(params.get('autoConnect')) && - isEnabled(params.get('headless')) + isEnabled(readUrlParam(params, 'oobEnable')) && + isEnabled(readUrlParam(params, 'autoConnect')) && + isEnabled(readUrlParam(params, 'headless')) ); } diff --git a/src/core/cloudxr/python/launcher.py b/src/core/cloudxr/python/launcher.py index 16cabaa60..74cda00b9 100644 --- a/src/core/cloudxr/python/launcher.py +++ b/src/core/cloudxr/python/launcher.py @@ -17,6 +17,7 @@ import logging import os import signal +import socket import subprocess import sys import threading @@ -388,13 +389,34 @@ def _resolve_start_wss_proxy( return start_wss_proxy return bool(getattr(args, "launch_wss_proxy", True)) + @staticmethod + def _is_local_tcp_port_open(port: int) -> bool: + """Return true when a local TCP listener accepts connections on *port*.""" + try: + with socket.create_connection(("127.0.0.1", port), timeout=0.25): + return True + except OSError: + return False + @staticmethod def _has_sourced_cloudxr_runtime_env() -> bool: - """Return true when the current shell looks sourced from ``cloudxr.env``.""" - return bool( + """Return true when sourced runtime markers point to live local services.""" + if not ( os.environ.get("XR_RUNTIME_JSON") and os.environ.get("NV_CXR_RUNTIME_DIR") + ): + return False + + from .oob_teleop_env import ( # noqa: PLC0415 + cloudxr_server_port, + wss_proxy_port, ) + try: + ports = (cloudxr_server_port(), wss_proxy_port()) + except ValueError: + return False + return all(CloudXRLauncher._is_local_tcp_port_open(port) for port in ports) + @staticmethod def _resolve_launch_cloudxr_runtime(args: argparse.Namespace) -> bool: """Resolve auto-launch behavior for documented two-terminal workflows.""" diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index 32254576a..8fe370976 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -480,7 +480,19 @@ def test_resolve_launch_cloudxr_runtime_auto_reuses_sourced_env( args = argparse.Namespace(launch_cloudxr_runtime=None) - assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=True): + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False + + def test_resolve_launch_cloudxr_runtime_restarts_stale_sourced_env( + self, monkeypatch + ) -> None: + monkeypatch.setenv("XR_RUNTIME_JSON", "/tmp/cloudxr/openxr.json") + monkeypatch.setenv("NV_CXR_RUNTIME_DIR", "/tmp/cloudxr/run") + + args = argparse.Namespace(launch_cloudxr_runtime=None) + + with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=False): + assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True def test_resolve_launch_cloudxr_runtime_explicit_overrides_env( self, monkeypatch @@ -502,8 +514,9 @@ def test_launch_context_reuses_sourced_runtime_in_auto_mode( args = argparse.Namespace(launch_cloudxr_runtime=None) - with CloudXRLauncher.launch_context(args) as launcher: - assert launcher is None + with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=True): + with CloudXRLauncher.launch_context(args) as launcher: + assert launcher is None @_windows_skip def test_launch_context_starts_when_enabled(self, tmp_path) -> None: From 069bb07ef3bef035c065d1626cbf02a2ba439e8f Mon Sep 17 00:00:00 2001 From: Andrew Russell Date: Wed, 22 Jul 2026 17:44:28 -0400 Subject: [PATCH 25/25] Format Quick Start launcher tests --- src/core/cloudxr_tests/python/test_launcher.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/core/cloudxr_tests/python/test_launcher.py b/src/core/cloudxr_tests/python/test_launcher.py index 8fe370976..6e77adf17 100644 --- a/src/core/cloudxr_tests/python/test_launcher.py +++ b/src/core/cloudxr_tests/python/test_launcher.py @@ -480,7 +480,9 @@ def test_resolve_launch_cloudxr_runtime_auto_reuses_sourced_env( args = argparse.Namespace(launch_cloudxr_runtime=None) - with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=True): + with patch.object( + CloudXRLauncher, "_is_local_tcp_port_open", return_value=True + ): assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is False def test_resolve_launch_cloudxr_runtime_restarts_stale_sourced_env( @@ -491,7 +493,9 @@ def test_resolve_launch_cloudxr_runtime_restarts_stale_sourced_env( args = argparse.Namespace(launch_cloudxr_runtime=None) - with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=False): + with patch.object( + CloudXRLauncher, "_is_local_tcp_port_open", return_value=False + ): assert CloudXRLauncher._resolve_launch_cloudxr_runtime(args) is True def test_resolve_launch_cloudxr_runtime_explicit_overrides_env( @@ -514,7 +518,9 @@ def test_launch_context_reuses_sourced_runtime_in_auto_mode( args = argparse.Namespace(launch_cloudxr_runtime=None) - with patch.object(CloudXRLauncher, "_is_local_tcp_port_open", return_value=True): + with patch.object( + CloudXRLauncher, "_is_local_tcp_port_open", return_value=True + ): with CloudXRLauncher.launch_context(args) as launcher: assert launcher is None