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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ Integrate your 3D display hardware into DisplayXR.
- [ADR-042](adr/ADR-042-vendor-2d3d-conversion-supersedes-default.md) — A vendor 2D→3D conversion module supersedes the open default — the runtime exposes it, weaving stays the DP's
- [ADR-043](adr/ADR-043-stereo-camera-source.md) — A display's stereo camera is a plug-in-provided source, owned by the service and privacy-gated by the runtime
- [ADR-044](adr/ADR-044-colour-contract-per-backend.md) — The colour contract, per backend and swapchain format
- [ADR-045](adr/ADR-045-plugins-always-loadable-and-report-platform-state.md) — Plug-ins are always loadable and report their platform state
<!-- END ADR INDEX -->

---
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
# ADR-045: Plug-ins are always loadable and report their platform state

**Status:** Proposed (2026-10-02) · extends [ADR-019](ADR-019-vendor-plugin-aux-boundary.md) and
[ADR-020](ADR-020-plugin-abi-compatibility-policy.md) (append-only slot, ABI unchanged) · epic
[#1803](https://github.com/DisplayXR/displayxr-runtime/issues/1803) (#1804, #1805) · spec:
[plugin-discovery.md §4.1–4.2](../specs/runtime/plugin-discovery.md)

## In one paragraph

Three things are installed independently on a DisplayXR machine: the **runtime**, a **vendor
plug-in**, and the **vendor platform runtime** that plug-in drives (plus, optionally, a vendor
conversion runtime). Users install them in any order, uninstall them in any order, and plug the 3D
display in or out at any time. The runtime must reach the right state in every case **without
knowing which vendor it is dealing with**. It does so with three rules. A registered plug-in is a
fact. A plug-in is always loadable, and it tells the runtime, in generic terms, why it cannot
serve the display right now. The long-lived service re-evaluates its choice when the world changes,
but it never takes the display away from a vendor plug-in that is already serving it.

## Context

Before this decision, every one of these orders had a failure mode, and each failed silently:

| Situation | Before |
|---|---|
| Runtime + plug-in installed, vendor platform installed **later** | The plug-in imported the platform's libraries statically, so the OS loader refused it. The runtime silently fell back to the simulation display. A service started before the platform keeps its logon-time `PATH`, so it kept failing until it was restarted. |
| Vendor platform installed, its service **not yet running** at service start | The plug-in declined. A re-probe happened only on the next **client** connect. When it did adopt the plug-in, the adoption was partial: the weaving DP and the geometry switched, but the head device (mode table, eye tracking) stayed the fallback's. |
| Display **not connected** at service start | Probe could block for many seconds waiting for the platform, and it was re-tried only on client events. Plugging the display in later did nothing until an app launched. |
| Display **disconnected** while running | Nothing noticed. |
| Any degraded state | Nothing in the tray or the Control Panel. Only `displayxr-cli selftest` and the logs showed it. |

There were three root causes. Load-time coupling of plug-in and platform. Selection that was
re-evaluated only on client events, with a probe that could block. No vocabulary for a plug-in to
say "I am fine, my platform is not".

## Decisions

### D1. A registered plug-in is a fact, not a decision

The `DisplayProcessors` registration (registry key / manifest) records that a plug-in is installed.
It is not a claim that the hardware is present. The runtime never deletes or rewrites a vendor
registration because the plug-in cannot be used right now. An **orphan** registration (its binary
is gone) is skipped with one WARN per process. The runtime installer owns only its own fallback
registration (installer work item, same epic).

### D2. Plug-ins are loadable without their platform, and report a generic platform state

Every plug-in binary must load when its vendor platform is absent. It resolves vendor libraries
lazily, by a path it derives itself, and never relies on the host process's `PATH`. It also reports
its state through a new optional slot, `xrt_plugin_iface::get_platform_state`. The slot is appended
per ADR-020, at an unchanged ABI, and carries `XRT_PLUGIN_HAS_PLATFORM_STATE`:

| State | Meaning |
|---|---|
| `READY` | Platform installed, running, and its display attached. |
| `PLATFORM_ABSENT` | The vendor platform runtime is not installed. |
| `PLATFORM_NOT_RUNNING` | It is installed, but its service is not running. |
| `NO_DISPLAY` | The platform is up, but none of its displays is attached. |
| `INCOMPATIBLE` | The platform is present but unusable (version, OS, GPU). |
| `UNKNOWN` | Not reported: an older plug-in, or the call returned false. |

Each state comes with a short **hint** that the vendor writes ("install the … runtime", "connect
the display"). The runtime shows the hint verbatim and never parses it. A `FALLBACK` flag marks a
plug-in that claims any system; today that is only the in-tree simulation display.

The slot takes no instance, so it can be called **before `probe()`**. The loader sequence is
load → negotiate → `get_platform_state` → `probe`, which lets a plug-in that is about to decline
say why. Both calls are presence checks only, with a budget of about 100 ms. They must never wait
for the platform: readiness waits belong in device or DP creation, or on a thread the plug-in owns.
The state is advisory. It never gates loading, and the runtime acts on it only generically.

### D3. Re-select on world events, but never swap a live vendor plug-in

The service re-evaluates selection on four triggers:
- display topology changes (`WM_DISPLAYCHANGE`);
- device-node changes (`WM_DEVICECHANGE` / `DBT_DEVNODES_CHANGED`);
- changes under the registration root (`RegNotifyChangeKeyValue`);
- a 10 s timer, only while the active plug-in is the fallback.

Re-probes are debounced (one refresh at least 1 s after the last event of a burst) and run on a
worker thread, never on the window thread or the IPC main loop. In-process apps do not re-probe on
world events: each app process selects once.

A refresh adopts a better-ranked plug-in **only while the active one is the fallback**. While a
vendor plug-in is active, the refresh changes nothing, even when that plug-in reports `NO_DISPLAY`.
The runtime surfaces the state instead, and the plug-in's DP passes pixels through unwoven. We
rejected a live swap between two DPs mid-session (for example, falling back to the simulation
display when the panel is unplugged). It would tear down live sessions' weavers and their
mode-table snapshots for a state that is usually transient (a cable, sleep, a monitor input switch).

### D4. Adoption is complete, through a restart when idle

When a refresh adopts a vendor plug-in, the weaving DP and the display info follow it immediately,
as before. The **head device** cannot follow it live. That device is the fallback's: its rendering
modes, its eye tracking, its pose binding. The system compositor holds it, it sized the worst-case
atlas, and every connected client holds a shared-memory snapshot of its mode table. Instead, the
system marks the head as stale. Once no client has been connected for 2 s, the service ends its
main loop and starts a successor of itself. The successor waits for the old process to exit, then
builds its whole system on the new plug-in. Clients that were connected keep running on the
partial adoption until they reconnect. A successor never restarts itself for the same reason again,
so a plug-in whose probe flaps cannot cause a restart loop.

### D5. Surface the state everywhere a user looks

- `displayxr-cli info` and `selftest` print one line per registered plug-in: the registration's own
name and version, its platform state and hint, and the loader outcome. The outcomes are `ACTIVE`,
`LOADED`, `DECLINED`, `BINARY_MISSING`, `DEPENDENCY_MISSING`, `ABI_MISMATCH`, and so on. The
`vendor_dp` self-test note carries the rejected plug-in's state. Exit codes do not change.
- The service tray tooltip names the active display processor. When that is the fallback, it adds
the reason the better-ranked plug-in gave. When the active plug-in reports `NO_DISPLAY`, it says
so.
- The Control Panel lists the same per-plug-in lines and highlights the degraded ones.

## Consequences

- A vendor plug-in built against older headers keeps working unchanged. It reports `UNKNOWN`, and
its load failures are still classified (binary missing vs dependency missing vs other).
- The fallback decision is made from the plug-in's own flag. For a simulation plug-in too old to
report state, the decision falls back to the runtime's own simulation id. It is never made from a
vendor id.
- Repeated identical load failures during re-probes log at INFO after the first WARN, so a box
whose vendor platform is absent costs one WARN per process, not one every 10 s.
- The service can now restart itself, once, to complete an adoption. That is a new lifecycle event.
It is logged ("plug-in adoption: no client connected — restarting the service …") and happens
only after the fallback was active and a vendor plug-in was adopted.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,4 @@
- [ADR-042](ADR-042-vendor-2d3d-conversion-supersedes-default.md) — A vendor 2D→3D conversion module supersedes the open default — the runtime exposes it, weaving stays the DP's
- [ADR-043](ADR-043-stereo-camera-source.md) — A display's stereo camera is a plug-in-provided source, owned by the service and privacy-gated by the runtime
- [ADR-044](ADR-044-colour-contract-per-backend.md) — The colour contract, per backend and swapchain format
- [ADR-045](ADR-045-plugins-always-loadable-and-report-platform-state.md) — Plug-ins are always loadable and report their platform state
81 changes: 80 additions & 1 deletion docs/specs/runtime/plugin-discovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ DisplayXR shell, …):
plug-in returns its own `xrt_plugin_iface *` and the API version
it speaks. Version mismatch → `XRT_ERROR_PROBER_NOT_SUPPORTED`,
skip.
- If the iface carries `get_platform_state` (§4.1), call it and record
the plug-in's platform state + hint against the entry — **before**
`probe()`, so a plug-in about to decline can still say why. The state
is advisory: it never stops the loader from calling `probe()`.
- Call `iface->probe(&inst)`. `XRT_ERROR_PROBER_NOT_SUPPORTED` is a
clean "no matching device" decline (logged at INFO); any other
`XRT_ERROR_*` is a hard failure (logged at WARN). Either way, the
Expand Down Expand Up @@ -445,6 +449,73 @@ vendor-neutral) and the Leia plug-in's entry point in
delegates to per-API DP factories and device-creation functions in its own
tree; the entry-point TU is short (~150 lines).

### 4.1 Loadable without the platform; `probe()` is cheap; report platform state (ADR-045)

A registered plug-in is a fact, not a decision: the runtime may enumerate
it on a machine where the vendor platform it drives is missing, not yet
running, or has no display attached — and the order in which the user
installs the runtime, the plug-in and the vendor platform is arbitrary.
Every plug-in therefore MUST:

1. **Load without its platform.** `LoadLibraryExW` / `dlopen` of the plug-in
binary must succeed when the vendor platform runtime is not installed.
Resolve vendor libraries lazily (delay-load / `dlopen` by a path the
plug-in derives itself) rather than as static imports the OS loader
must satisfy — and never rely on the host process's `PATH`, which in the
long-lived service is frozen at logon.
2. **Keep `probe()` and `get_platform_state()` within ~100 ms**, and never
block on the vendor platform becoming ready. Both run on the
`xrCreateInstance` hot path and on every re-probe (§4.2). Presence
checks only — a registry value, a named kernel object, an EDID table
lookup. A readiness wait belongs in `create_device` / the DP factory
(or a background thread the plug-in owns), not in `probe()`.
3. **Report its platform state** through the optional
`xrt_plugin_iface::get_platform_state` slot (`XRT_PLUGIN_HAS_PLATFORM_STATE`,
appended per ADR-020 at unchanged ABI):

| `xrt_plugin_platform_state` | Meaning |
|---|---|
| `UNKNOWN` (0) | Not reported (slot absent, older plug-in, call returned false). Runtime behaves as before. |
| `READY` | Platform installed, running, display attached. |
| `PLATFORM_ABSENT` | The vendor platform runtime is not installed. |
| `PLATFORM_NOT_RUNNING` | Installed, its service/daemon is not running. |
| `NO_DISPLAY` | Platform up, none of its displays attached. |
| `INCOMPATIBLE` | Platform present but unusable (version, OS, GPU, …). |

plus `hint` — a short (≤ 127 bytes UTF-8) vendor-written sentence the
runtime shows verbatim and never parses ("install the … runtime",
"connect the display"), and `flags`. `XRT_PLUGIN_PLATFORM_FLAG_FALLBACK`
marks a plug-in that claims any system (the in-tree sim-display); vendor
plug-ins MUST NOT set it.

Call sequence: **load → negotiate → `get_platform_state` → `probe`**.
The slot takes no instance, is thread-safe, and may also be called at any
time after selection (diagnostics poll it while the plug-in is active).

### 4.2 Re-probe and the no-live-swap rule

The long-lived service re-evaluates selection on world events — display
topology change, device-node change, a change under the
`DisplayProcessors` registry key, and a slow timer while the active plug-in
is the fallback — debounced to at most one refresh per second. A refresh
adopts a better (lower `ProbeOrder`) plug-in **only while the active one
carries `XRT_PLUGIN_PLATFORM_FLAG_FALLBACK`** (or, for a plug-in that does
not report state, has id `sim-display`). An active vendor plug-in that
reports `NO_DISPLAY` is kept: the runtime surfaces the state (tray,
`displayxr-cli info` / `selftest`) and the DP passes pixels through
unwoven. In-process apps do not re-probe on world events.

**Adoption is complete via a restart when idle.** On adoption the weaving DP
and display info follow the new plug-in at once, but the head device (mode
table, eye tracking) was created by the fallback and is held by the system
compositor and every client's shared-memory snapshot, so it cannot be swapped
live. The system marks it stale (`xrt_system_compositor_info::head_device_stale`);
once no IPC client has been connected for 2 s the Windows service ends its main
loop and starts a successor (`--adoption-restart-after-pid <pid>`), which waits
for the old process to exit and builds its system on the new plug-in. A
successor never restarts itself for adoption again (no loop on a flapping
probe).

---

## 5. Cascade-uninstall
Expand Down Expand Up @@ -576,14 +647,22 @@ The runtime emits these one-shot lines at instance creation, all at
| `plugin loader: <N> registered plug-in(s); attempting in ProbeOrder ascending.` | INFO — entry count after enumeration. Suppressed at default WARN level. |
| `plugin loader: [i/N] <id> (ProbeOrder=<order>, <path>)` | INFO — per-entry attempt trace. Suppressed at default WARN level. |
| `plugin loader: <id>: probe declined (no matching device).` | INFO — clean decline from probe (`XRT_ERROR_PROBER_NOT_SUPPORTED`). Suppressed at default WARN level. |
| `plugin loader: <id>: LoadLibrary(<path>) failed (err=<n>).` | WARN — DLL load failure. Surfaces in default log. |
| `plugin loader: <id>: LoadLibrary(<path>) failed (err=<n>).` | WARN — DLL load failure. `err=126` with the binary present adds "a library the plug-in imports is missing" (`DEPENDENCY_MISSING`). |
| `plugin loader: <id>: registered Binary '<path>' does not exist — skipping (orphan registration).` | WARN — `BINARY_MISSING` (ADR-045). |
| `plugin loader: <id>: platform state <STATE>[ — <hint>]` | WARN on change — what `get_platform_state` reported, before `probe()` (ADR-045). |
| `plug-in adoption: '<old>' -> '<new>' — weaving DP and display info follow now; …` | WARN — a refresh adopted a better plug-in under the fallback's head device (service). |
| `plug-in adoption: no client connected — restarting the service so the head device follows '<id>'.` | WARN — the adoption restart (Windows service). |

| `plugin loader: <id>: missing entry point 'xrtPluginNegotiate' — skipping.` | WARN — DLL has no negotiate symbol. Plug-in DLL is structurally invalid. |
| `plugin loader: <id>: negotiate returned <code> (iface=<ptr>) — skipping.` | WARN — negotiate failure. Usually version mismatch. |
| `plugin loader: <id>: probe returned <code> — skipping.` | WARN — probe failure other than the clean `XRT_ERROR_PROBER_NOT_SUPPORTED` decline. |
| `plugin loader: active plug-in: id=<id> name='<name>' vendor='<vendor>' version='<version>' plugin_api=<v> probe_order=<n> path=<path>` | WARN — the winning plug-in. Authoritative line for "which DP shipped this session." |
| `plugin loader: no registered plug-in claimed the system — falling back to static drivers.` | WARN — every entry failed / declined. **The message is stale**: the static-link fallback was removed in #287 (see §7), so nothing loads and instance creation fails. |
| `plugin loader: registry root HKLM\Software\DisplayXR\DisplayProcessors absent (rc=<n>) — no plug-ins to try.` | INFO — no plug-ins registered. Same stale "static" wording as above; there is no fallback. Suppressed at default WARN. |

Load failures, the `loading plug-in binary` breadcrumb, and the #461 skew check WARN on the **first**
attempt per plug-in per process (or when the outcome changes); repeats from re-probes log at INFO.

Vendor support flows checking "is the plug-in actually loading"
should look for the `active plug-in:` line in
`%LOCALAPPDATA%\DisplayXR\DisplayXR_<exe>.<pid>_<ts>.log` (Windows) or
Expand Down
18 changes: 18 additions & 0 deletions src/xrt/drivers/sim_display/sim_display_plugin.c
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,22 @@ sim_display_plugin_probe_displays(struct xrt_plugin_instance *inst,
*
*/

/*!
* ADR-045 platform state. sim_display has no platform to be missing: it is
* always READY, and it is the FALLBACK plug-in — the runtime adopts a vendor
* plug-in on re-probe only while this one is active.
*/
static bool
sim_display_plugin_get_platform_state(struct xrt_plugin_platform_status *out_status)
{
if (out_status == NULL || out_status->struct_size < offsetof(struct xrt_plugin_platform_status, hint)) {
return false;
}
out_status->state = XRT_PLUGIN_PLATFORM_STATE_READY;
out_status->flags = XRT_PLUGIN_PLATFORM_FLAG_FALLBACK;
return true;
}

static struct xrt_plugin_iface g_sim_display_iface = {
.struct_size = sizeof(struct xrt_plugin_iface),
.reserved_0 = 0,
Expand Down Expand Up @@ -265,6 +281,8 @@ static struct xrt_plugin_iface g_sim_display_iface = {
#else
.create_dp_d3d11_lift = NULL,
#endif

.get_platform_state = sim_display_plugin_get_platform_state,
};


Expand Down
Loading
Loading