Launcher and process model#
Application samples own their DeviceIOHub and worker processes. Model services may
instead be declared with launch_mode="reuse" and started separately through the
shared model-servers stack.
An application sample has at least these two sub-projects:
Sub-project |
Role |
Dependencies |
|---|---|---|
|
Orchestrator — declares owned and reused processes in code |
|
|
Agent worker — connects to hub via IPC, runs agent logic |
|
Samples may add capability or eval sub-projects. model-servers is a dedicated
orchestrator and has no worker sub-project.
Configuration convention — the YAML configuration path for each process is declared
explicitly in the orchestrator’s PROCESSES list via the config= field of
Process. The launcher passes it as --config <path> to the subprocess.
Application and service configuration normally lives in the sample’s yaml/
directory. A capability sub-project may keep its configuration beside that
project, such as xr-render-demo/scene/scene_service.yaml. Omit config= for
processes that use their own internal defaults.
Samples that support interchangeable local and hosted models may set
models_config in the worker YAML. The worker SDK’s load_models_config()
accepts omitted deployment metadata and defaults it to external ownership.
Consumer samples declare only application processes and let workers connect to
the configured endpoints; their launchers do not read model profiles.
Launcher profile loaders require the wrapped JSON form with adapter,
endpoint, and deployment objects for each model. load_model_deployment()
reads the profile selected by the worker YAML, while load_deployment_profile()
reads a profile path directly. Both use only the standard library and expose
deployment metadata and credential requirements. The shared model-servers
sample uses the direct loader and managed entries to select servers. Legacy
explicit reused and external deployment entries remain accepted. Refer to
model profile formats for the worker and launcher
requirements.
The orchestrator declares the process sequence in code:
_BASE = Path(__file__).resolve().parent # sample root
PROCESSES = [
Process("hub", "../../services/device-io-hub", "device_io_hub",
config="yaml/device_io_hub.yaml"),
Process("worker", "worker", "my_agent_worker",
config="yaml/my_agent_worker.yaml"),
# Optional shared components — add as needed:
# Process("cloudxr", "../../services/cloudxr-runtime", "cloudxr_runtime",
# config="yaml/cloudxr_runtime.yaml"),
]
def run() -> None:
run_stack(PROCESSES, _BASE)
Rules#
Spawned stack items start in declaration order — non-
reusemembers of aParallelitem start concurrently, and the launcher waits for each spawnedProcessorParallelmember to create its--ready-filebefore starting the next item. Declare items in dependency order (hub before workers and application processes after the services they call).Every spawned process accepts
--ready-file <path>and mustPath(path).touch()when it is fully initialized and ready to serve requests.Native voice workers pass the ready file to
VoiceAgent; its private media session touches it only after the input transport’s hub IPC receive loop has started.device_io_hubalways runs as its own process — never embedded in-process.The worker never imports anything from
device_io_huborxr_ai_launcher.Process management lives in
utils/xr-ai-launcher/, not inside any process it manages.run_stackis fail-fast while monitoring: if any spawned process exits, the rest are terminated.
Serial and parallel items#
The stack is declared as a sequence of Process or Parallel items:
Process— when not configured withlaunch_mode="reuse", started alone; the launcher waits for it to signal ready before moving on.Parallel([p1, p2, ...])— all non-reuseprocesses in the group are started at once; the launcher waits for every spawned member to signal ready before the next item in the sequence begins. If any spawned member exits before signaling ready, the launcher shuts everything down, just as it would for a serial process.
PROCESSES = [
Process("vlm", "../../services/vlm-server", "vlm_server",
launch_mode="reuse"),
Process(
"embedding", "../../services/embedding-server", "embedding_server",
launch_mode="reuse",
),
Process("hub", "../../services/device-io-hub", "device_io_hub",
config="yaml/device_io_hub.yaml"),
Parallel([
Process("video-memory", "../../services/video-memory-service",
"video_memory_service", config="yaml/video_memory_service.yaml"),
Process("rag", "../../services/rag-service", "rag_service",
config="yaml/rag_service.yaml"),
]),
Process("worker", "worker", "my_agent_worker"),
]
How run_stack works#
For each spawned process (an entry not configured with launch_mode="reuse"),
the launcher:
Resolves the project directory and YAML configuration from the sample root (
base— all relative paths inProcess.projectandProcess.configare resolved against it).Spawns
uv run --project <dir> <command> --config <yaml> --ready-file <f>in a new process group, so the whole group (uvplus its children) can be torn down together rather than leaving orphans.Waits for the process to create
(the ready file), recording a DEBUG progress line every five seconds. It is visible with XR_AI_VERBOSEand in the per-run log.Once all processes are ready, monitors them: any exit triggers a graceful shutdown of the rest (SIGTERM, escalating to SIGKILL after a timeout).
Each spawned process is responsible for creating its own ready file at the moment it is fully initialized and able to serve requests — after model warm-up, after the IPC socket connects, after the HTTP server starts listening, etc.
Pass exit_after_ready=True to run_stack to return immediately once
everything is ready instead of monitoring — useful for launchers whose
processes are all launch_mode="persist" and are designed to outlive the
orchestrator (e.g. model-servers). For a persistent process, this also tells
a bootstrap that reused an already-running service that it may exit after
signaling readiness. Without exit_after_ready=True, the bootstrap remains
alive so the launcher can continue monitoring it.
The --ready-file protocol#
The launcher injects --ready-file <path> into every spawned command. The
process must Path(path).touch() the moment it is fully initialized and able
to serve requests. The launcher blocks on the file’s existence; if the process
exits before creating it, startup is aborted and the whole stack is torn down.
This makes readiness explicit and process-defined: a model server signals ready
after weights load, an HTTP server after it starts listening, a worker after
its IPC receive loop is active.
launch_mode: own, persist, reuse#
Process.launch_mode controls spawn and shutdown behaviour:
"own"(default) — the launcher spawns this process and kills it on shutdown."persist"— the launcher spawns this process but leaves it running on shutdown. Use for heavy model servers that need to survive stack restarts (e.g. vLLM containers). Cleanup is the caller’s responsibility. The optionalportfield is metadata;model-serversuses it to select cleanup targets, while genericrun_stackdoes not inspect it."reuse"— the launcher does not spawn this process; it is assumed to be already running (e.g. started bymodel-servers). The entry in the process list documents the dependency; the launcher skips it entirely and does not kill it on shutdown.
On a clean ready-exit, persist and reuse processes are left running. On an
abort during startup (Ctrl-C, or a process exiting before it signals ready)
the launcher tears down everything, including persist processes, so no
half-started service is left behind.
Adding a new managed process#
There is no per-process launcher module to write — the launcher spawns any uv sub-project generically. To add a new process to a stack:
Make the sub-project’s entry-point command accept
--ready-file <path>(touch it once ready) and, if it takes configuration,--config <path>.Add a
Process(orParallel) entry to the orchestrator’sPROCESSESlist, in dependency order, pointing at the sub-project directory and its entry-point command — exactly like thehubandworkerentries above.