Skip to content

feat(cloudxr): supervised background runtime (systemd user service) - #927

Draft
jiwenc-nv wants to merge 6 commits into
mainfrom
jiwenc-nv/cloudxr-user-service
Draft

feat(cloudxr): supervised background runtime (systemd user service)#927
jiwenc-nv wants to merge 6 commits into
mainfrom
jiwenc-nv/cloudxr-user-service

Conversation

@jiwenc-nv

Copy link
Copy Markdown
Collaborator

Description

Fixes #919.

Draft — opening early for design review. One finding reshaped the approach: a systemd unit alone doesn't fix what #919 complains about. CloudXRLauncher.__init__ runs fuser -k -TERM on ~/.cloudxr/run/ipc_cloudxr whether or not a live runtime owns it, and --launch-cloudxr-runtime defaults to true. With a supervised runtime up, any example run with default flags kills it, systemd restarts it, and the headset reconnects anyway — worse than today's two-terminal workflow.

So this lands two halves together: the launcher becomes idempotent (detect → attach → tear down only what it started), and a user unit runs python -m isaacteleop.cloudxr so runtime + WSS proxy + OOB hub outlive the calling script.

Sequence (one commit each, first is pushed):

  • runtime_is_live() — detect a live runtime without touching its socket
  • Attach + ownership in CloudXRLauncher; gate both stomping sites
  • Proxy attach (port probe)
  • Drop --no-launch-cloudxr-runtime; registrar stays a deprecated no-op
  • Attach-aware banner in __main__
  • Rig liveness gate
  • ensure_headset_session() + CONTROL_TOKEN to the run dir
  • service.py, unit template, docs

Type of change

  • New feature (non-breaking change which adds functionality)

Testing

pytest src/core/cloudxr_tests/python/ — 37 in test_runtime.py, 6 new. The /proc/net/unix parser was also checked against a real listener on a live host, since fixtures can't prove the column indices match the kernel's format.

Checklist

  • I have read and understood the contribution guidelines
  • I have run the linter and formatter with SKIP=check-copyright-year pre-commit run --all-files
  • I have made corresponding changes to the documentation
  • I have added tests that prove my fix/feature works (or explained why not)
  • I have signed off all my commits (git commit -s) per the DCO

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Enterprise

Run ID: 90187d40-b507-49eb-8a9c-649d51bb8a29

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

A second launch treated the existence of `ipc_cloudxr` as proof of a stale
runtime and `fuser -k -TERM`ed whoever held it — including a healthy runtime
mid-session, which the first session saw as a broken pipe. When `fuser` was
missing the error was swallowed and the socket unlinked anyway, leaving the old
process running and the operator to clean `~/.cloudxr/run/` by hand.

Liveness is now decided by connecting to the socket rather than by stat-ing it.
A live runtime makes the launcher refuse, pointing at the env file and
`--no-launch-cloudxr-runtime`; replacing it means stopping it yourself. A dead
socket is cleaned with a warning. Ambiguous probe errors count as live, since
refusing is recoverable and clobbering a session is not.

`-35` is thrown after `xrCreateInstance` succeeds, so the runtime was found and
only the headset is missing, but library defaults are fail-fast and a direct
consumer got a bare `Failed to get OpenXR system: -35`. Both throw sites now
name `XR_ERROR_FORM_FACTOR_UNAVAILABLE` and point at the docs. The device
profile is the usual culprit, so the startup banner prints the resolved value
and the launcher/env-config defaults agree on one constant — the docs claimed
`auto-webrtc` where `Quest3` always won. Overriding it stays an env-file or
`CloudXRLauncher(device_profile=...)` job; Apple Vision Pro needs
`auto-native`.

Closes #908

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
Comment thread src/python/isaacteleop/cloudxr/service/_service.py Fixed
Comment thread src/python/isaacteleop/cloudxr/service/_service.py Fixed
Comment thread src/python/isaacteleop/cloudxr/service/_service.py Fixed
Comment thread src/python/isaacteleop/cloudxr/service/_service.py Fixed
CloudXRLauncher both owned the runtime process and served as the API for
reaching one, which left ownership invisible at the call site: whether a
teleop script's runtime outlived it depended on how the host was set up,
not on anything in the script.

CloudXRService now holds the runtime subprocess, the WSS proxy thread and
their teardown. CloudXRLauncher keeps the CLI plumbing and delegates
lifecycle to a service it constructs, so behaviour is unchanged here; a
later commit turns it into an attach-only client.

Lifecycle tests move to test_service.py with it, and their doubles move to
conftest.py so both suites share them.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
python -m isaacteleop.cloudxr.service run is what a supervisor executes;
install/uninstall/status/logs manage the systemd user service around it.
python -m isaacteleop.cloudxr keeps working as a deprecated alias for run.

service becomes a package so the CLI lives in its own __main__: as a flat
module, python -m re-executed it after the parent package had already
imported it, leaving two CloudXRService classes and a RuntimeWarning.

The unit is Restart=on-failure rather than always — a rejected EULA and the
--setup-oob adb preflights both exit non-zero with no headset attached,
which is the normal state at boot. Install refuses unless the EULA marker
exists or --accept-eula is passed, and records acceptance as a marker in
the operator's own process: a service started by systemd has no stdin.

Where there is no user systemd, every unit command fails with instructions
naming both escapes (run the service yourself, or run_embedded).

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
parts.append(
f"stderr: {stderr_tail.decode(errors='replace').strip()}"
)
except Exception:
if len(content) > _MAX_LOG_BYTES:
content = "...\n" + content[-_MAX_LOG_BYTES:]
parts.append(f"{log_path.name}:\n{content}")
except Exception:
return
try:
proc.wait(timeout=RUNTIME_TERMINATE_TIMEOUT_SEC)
except subprocess.TimeoutExpired:
return
try:
proc.wait(timeout=RUNTIME_TERMINATE_TIMEOUT_SEC)
except subprocess.TimeoutExpired:
systemctl --user needs a per-user manager, which containers and CI runners
do not have — PID 1 is the entrypoint, so the manager never starts. That
left every unit command dead on exactly the hosts this is most useful on,
with no fallback but a wall of instructions.

service start now spawns the detached run with start_new_session=True,
which is setsid(2): the child leads its own session, so neither a hangup
nor a signal aimed at the shell's process group reaches it. Works on a
host, in a container, and under CI, with no dependency beyond util-linux
semantics Python already exposes.

status asks the IPC socket rather than a pid file, since pids are reused
and a socket cannot lie. stop is SIGTERM only: the runtime leads its own
session, so killing the service outright would orphan a process holding
the GPU.

What this gives up, relative to the systemd unit it replaces: no restart
after a crash, and no start at boot.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
@jiwenc-nv
jiwenc-nv force-pushed the jiwenc-nv/cloudxr-user-service branch from 5676b31 to a76d946 Compare August 8, 2026 06:01
start printed three lines and status three more, so neither told you the
device profile, the log paths, or the web client URL — the things you
actually need to connect a headset.

Both now print the run banner. Two values the banner took from live state
had to be reachable from another process: the WSS log path, now found by
latest_wss_log(), and the device profile, now read out of the cloudxr.env
the service wrote. Neither goes through EnvConfig, because resolving it
here would rewrite that file underneath the running service — which is
also why the log finders take a logs_dir instead of the singleton.

status reconstructs the running session's flags from the service's own
command line, so it reports the session that exists rather than this
command's defaults.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
/proc/<pid>/cmdline NUL-terminates every argument, so splitting on NUL
leaves a trailing empty string. read_run_flags passed it to argparse,
which rejected it, and status exited 2 with 'unrecognized arguments'
against any detached service.

The tests missed it because they mocked read_run_flags with a clean list,
which cannot reproduce a terminator; the regression test reads a real
process's cmdline instead.

status also no longer dies when those flags do not parse. They come from
another process, possibly a build that knows options this one does not,
and a status command should still report what it can.

Signed-off-by: Jiwen Cai <jiwenc@nvidia.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Dedicated CloudXR runtime has no supervised background mode

1 participant