diff --git a/docs/README.md b/docs/README.md index 92c3ecfdf8..3a6cf764aa 100644 --- a/docs/README.md +++ b/docs/README.md @@ -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 --- diff --git a/docs/adr/ADR-045-plugins-always-loadable-and-report-platform-state.md b/docs/adr/ADR-045-plugins-always-loadable-and-report-platform-state.md new file mode 100644 index 0000000000..14476b48a1 --- /dev/null +++ b/docs/adr/ADR-045-plugins-always-loadable-and-report-platform-state.md @@ -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. diff --git a/docs/adr/README.md b/docs/adr/README.md index 78e8744dbe..4a68926b1f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -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 diff --git a/docs/specs/runtime/plugin-discovery.md b/docs/specs/runtime/plugin-discovery.md index 2ac3067f1f..59f3b46054 100644 --- a/docs/specs/runtime/plugin-discovery.md +++ b/docs/specs/runtime/plugin-discovery.md @@ -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 @@ -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 `), 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 @@ -576,7 +647,12 @@ The runtime emits these one-shot lines at instance creation, all at | `plugin loader: registered plug-in(s); attempting in ProbeOrder ascending.` | INFO — entry count after enumeration. Suppressed at default WARN level. | | `plugin loader: [i/N] (ProbeOrder=, )` | INFO — per-entry attempt trace. Suppressed at default WARN level. | | `plugin loader: : probe declined (no matching device).` | INFO — clean decline from probe (`XRT_ERROR_PROBER_NOT_SUPPORTED`). Suppressed at default WARN level. | -| `plugin loader: : LoadLibrary() failed (err=).` | WARN — DLL load failure. Surfaces in default log. | +| `plugin loader: : LoadLibrary() failed (err=).` | WARN — DLL load failure. `err=126` with the binary present adds "a library the plug-in imports is missing" (`DEPENDENCY_MISSING`). | +| `plugin loader: : registered Binary '' does not exist — skipping (orphan registration).` | WARN — `BINARY_MISSING` (ADR-045). | +| `plugin loader: : platform state [ — ]` | WARN on change — what `get_platform_state` reported, before `probe()` (ADR-045). | +| `plug-in adoption: '' -> '' — 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 ''.` | WARN — the adoption restart (Windows service). | + | `plugin loader: : missing entry point 'xrtPluginNegotiate' — skipping.` | WARN — DLL has no negotiate symbol. Plug-in DLL is structurally invalid. | | `plugin loader: : negotiate returned (iface=) — skipping.` | WARN — negotiate failure. Usually version mismatch. | | `plugin loader: : probe returned — skipping.` | WARN — probe failure other than the clean `XRT_ERROR_PROBER_NOT_SUPPORTED` decline. | @@ -584,6 +660,9 @@ The runtime emits these one-shot lines at instance creation, all at | `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=) — 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_._.log` (Windows) or diff --git a/src/xrt/drivers/sim_display/sim_display_plugin.c b/src/xrt/drivers/sim_display/sim_display_plugin.c index dca1906272..b2c8e80c5b 100644 --- a/src/xrt/drivers/sim_display/sim_display_plugin.c +++ b/src/xrt/drivers/sim_display/sim_display_plugin.c @@ -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, @@ -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, }; diff --git a/src/xrt/include/xrt/xrt_compositor.h b/src/xrt/include/xrt/xrt_compositor.h index 092c81e6ec..e603c1673c 100644 --- a/src/xrt/include/xrt/xrt_compositor.h +++ b/src/xrt/include/xrt/xrt_compositor.h @@ -3078,6 +3078,17 @@ struct xrt_system_compositor_info //! dp_factory_* came from; "" if none. Lets comp_dp_factory_for_window name //! both plug-ins when the registry and scalar diverge (#1521). char active_plugin_id[64]; + + //! ADR-045: the active plug-in is a FALLBACK (XRT_PLUGIN_PLATFORM_FLAG_FALLBACK). + //! A long-lived service keeps re-probing on a slow timer only while this is set. + bool active_plugin_is_fallback; + + //! ADR-045: a refresh adopted a better plug-in AFTER the system's head device + //! was created by the previous one. The weaving DP and display info follow + //! the new plug-in at once, but the head device (mode table, eye tracking) + //! cannot be swapped under live sessions, so a complete adoption needs a new + //! system: the service restarts itself once no client is connected. + bool head_device_stale; }; struct xrt_system_compositor; diff --git a/src/xrt/include/xrt/xrt_plugin.h b/src/xrt/include/xrt/xrt_plugin.h index 16a8f61097..8e11724f37 100644 --- a/src/xrt/include/xrt/xrt_plugin.h +++ b/src/xrt/include/xrt/xrt_plugin.h @@ -246,6 +246,81 @@ struct xrt_display_claim char serial[64]; }; +/*! + * Generic state of the vendor platform a plug-in drives (ADR-045), reported + * through `xrt_plugin_iface::get_platform_state`. Vendor-neutral by design: + * the runtime acts only on these values and shows the plug-in's hint string + * verbatim; it never learns what the platform is. + * + * Values are stable ABI (appended only). + */ +enum xrt_plugin_platform_state +{ + //! Not reported: slot absent (older plug-in), call returned false, or a + //! value this runtime does not know. Treated as "carry on as before". + XRT_PLUGIN_PLATFORM_STATE_UNKNOWN = 0, + //! Platform installed, running, and its display is attached. + XRT_PLUGIN_PLATFORM_STATE_READY = 1, + //! The vendor platform runtime is not installed on this machine. + XRT_PLUGIN_PLATFORM_STATE_PLATFORM_ABSENT = 2, + //! Installed, but its service/daemon is not running (yet). + XRT_PLUGIN_PLATFORM_STATE_PLATFORM_NOT_RUNNING = 3, + //! Platform present and running, but none of its displays is attached. + XRT_PLUGIN_PLATFORM_STATE_NO_DISPLAY = 4, + //! Platform present but unusable by this plug-in (version too old/new, + //! unsupported OS or GPU, ...). The hint says what. + XRT_PLUGIN_PLATFORM_STATE_INCOMPATIBLE = 5, +}; + +/*! + * Bits for @ref xrt_plugin_platform_status::flags. + * @{ + */ +/*! + * This plug-in is a FALLBACK (e.g. the vendor-neutral simulation display): + * it claims any system so the runtime always has a display processor. The + * runtime adopts a better plug-in on re-probe only while the active one + * carries this bit — the "no live swap" rule (ADR-045 D3). Vendor plug-ins + * MUST NOT set it. + */ +#define XRT_PLUGIN_PLATFORM_FLAG_FALLBACK (1u << 0) +/*! @} */ + +/*! Size of @ref xrt_plugin_platform_status::hint, including the NUL. */ +#define XRT_PLUGIN_PLATFORM_HINT_MAX 128 + +/*! + * Out-param of `xrt_plugin_iface::get_platform_state`. The runtime sets + * @ref struct_size and zero-fills the rest before the call; the plug-in + * MUST NOT write past `struct_size`. Grows by consuming @ref reserved + * (append-only; no ABI bump). + */ +struct xrt_plugin_platform_status +{ + /*! `sizeof(struct xrt_plugin_platform_status)` as the RUNTIME knows it. */ + uint32_t struct_size; + + /*! @ref xrt_plugin_platform_state value. */ + uint32_t state; + + /*! Bitmask of `XRT_PLUGIN_PLATFORM_FLAG_*`. */ + uint32_t flags; + + /*! Reserved for alignment. Must be 0. */ + uint32_t reserved_0; + + /*! + * Short, user-facing, vendor-written hint, UTF-8, NUL-terminated, + * truncated to fit — e.g. what to install or plug in. Empty when there + * is nothing to say (typically READY). Shown verbatim by the runtime's + * diagnostics (cli, tray); never parsed. + */ + char hint[XRT_PLUGIN_PLATFORM_HINT_MAX]; + + /*! Reserved for future fields. Plug-ins MUST leave these 0. */ + uint64_t reserved[8]; +}; + /* * @@ -811,6 +886,45 @@ struct xrt_plugin_iface * XRT_PLUGIN_API_VERSION_CURRENT bump). */ xrt_dp_factory_d3d11_fn_t create_dp_d3d11_lift; + + /*! + * Report the plug-in's generic platform state (ADR-045): whether the + * vendor platform it drives is installed, running, and has its display + * attached — plus a short vendor-written hint for the user. + * + * **Callable before `probe()` succeeds, and without an instance.** The + * loader calls it right after a successful negotiation and before + * `probe()`, so a plug-in that is about to decline can still say why; + * it calls it again on every re-probe, and the runtime's diagnostics + * (`displayxr-cli`, the service tray) call it at any time afterwards, + * including while the plug-in is the active one. The answer is + * therefore about the plug-in's process-wide view of its platform, not + * about one instance. + * + * Contract: + * - Cheap and non-blocking: presence checks only (a registry value, a + * named object, an EDID table lookup). MUST return within the same + * ~100 ms budget as `probe()` and MUST NOT wait for the vendor + * platform to become ready. Thread-safe: may be called from any + * thread, concurrently with the plug-in's other entry points. + * - The runtime sets `out_status->struct_size` to its own + * `sizeof(struct xrt_plugin_platform_status)` and zero-fills the + * rest before the call; the plug-in MUST NOT write past that offset. + * - Returns `true` if `out_status` was filled. `false` (or a NULL + * slot, or a plug-in whose `struct_size` predates this field) means + * "not reported": the runtime records + * @ref XRT_PLUGIN_PLATFORM_STATE_UNKNOWN and carries on exactly as + * before — the state is advisory and never gates loading. + * + * The runtime never interprets the hint; it only displays it. It does + * interpret the state and @ref XRT_PLUGIN_PLATFORM_FLAG_FALLBACK, and + * only generically (selection never re-routes away from an active + * non-fallback plug-in — "no live swap", ADR-045). + * + * Optional. Appended per ADR-020 (append-only within a major; gated by + * @ref struct_size; no XRT_PLUGIN_API_VERSION_CURRENT bump). + */ + bool (*get_platform_state)(struct xrt_plugin_platform_status *out_status); }; /*! @@ -820,6 +934,15 @@ struct xrt_plugin_iface */ #define XRT_PLUGIN_IFACE_HAS_D3D11_LIFT_FACTORY 1 +/*! + * Defined when @ref xrt_plugin_iface carries @ref + * xrt_plugin_iface::get_platform_state and this header defines + * @ref xrt_plugin_platform_state / @ref xrt_plugin_platform_status (ADR-045), + * so a plug-in built against an older runtime header can #ifdef-guard + * implementing it. + */ +#define XRT_PLUGIN_HAS_PLATFORM_STATE 1 + /* * diff --git a/src/xrt/ipc/server/ipc_server_interface.h b/src/xrt/ipc/server/ipc_server_interface.h index f0a98348d9..9760a09847 100644 --- a/src/xrt/ipc/server/ipc_server_interface.h +++ b/src/xrt/ipc/server/ipc_server_interface.h @@ -38,8 +38,41 @@ struct ipc_server_main_info //! When true, service runs in workspace mode with a shared multi-compositor window. bool workspace_mode; + + //! ADR-045: when true, the server ends its main loop (with + //! @ref ipc_server_restart_requested set) once a refresh has adopted a + //! better display plug-in under a head device the previous one created + //! and no client is connected, so the host can start a fresh instance + //! built on the new plug-in end to end. Only the standalone service sets + //! it; it must also know how to start that successor. + bool allow_adoption_restart; }; +/*! + * ADR-045 R-c: ask the server to re-evaluate display-processor selection + * (`refresh_display_processors`) because the world changed — display + * topology, device nodes, or the plug-in registration root. Cheap and + * non-blocking: it only flags a request for the server's re-probe worker, + * which debounces bursts (>= 1 s) and runs the refresh off the caller's + * thread, so it is safe from a window procedure or a registry waiter. A no-op + * before the server has started or after it stopped. @p reason must be a + * string literal (logged, not copied). + * + * @ingroup ipc_server + */ +void +ipc_server_request_display_reprobe(const char *reason); + +/*! + * True after @ref ipc_server_main returned because of an adoption restart + * (see @ref ipc_server_main_info::allow_adoption_restart): the host should + * start a successor instance. + * + * @ingroup ipc_server + */ +bool +ipc_server_restart_requested(void); + /*! * Main entrypoint to the compositor process. * diff --git a/src/xrt/ipc/server/ipc_server_process.c b/src/xrt/ipc/server/ipc_server_process.c index 9409801999..2a0fc7eaca 100644 --- a/src/xrt/ipc/server/ipc_server_process.c +++ b/src/xrt/ipc/server/ipc_server_process.c @@ -733,6 +733,213 @@ repull_display_info_if_unknown(struct ipc_server *s) // until they reconnect. See docs/reference/xrt_plugin_iface.md. } +#ifndef XRT_OS_ANDROID +/* + * + * ADR-045 R-c: world-event display re-probe + adoption restart. + * + * Selection used to be re-evaluated only on CLIENT events (connect, + * compositor create, the #1721 1 Hz pull while the display info is unknown). + * A display plugged in, a vendor platform coming up, or a plug-in registered + * while no client happened to connect went unnoticed until the next app + * launch. The service host now reports world events through + * ipc_server_request_display_reprobe(); a small worker debounces them and runs + * the same refresh callback the client paths use, plus a slow timer while the + * fallback plug-in is active (its platform may come up without any event we + * can see). The refresh itself never swaps away from a non-fallback plug-in + * (target_plugin_refresh_active, "no live swap"). + * + * The worker deliberately does NOT take global_state.lock: a plug-in that + * predates the ~100 ms probe rule can block inside probe() for many seconds, + * and the main loop takes that lock every tick. The refresh callback guards + * its own state (loader + display-info mutexes), exactly as on the + * compositor-create path, which also calls it without the global lock. + * + */ + +//! Bursts of world events (a dock connecting fires several DEVNODES_CHANGED + +//! DISPLAYCHANGE) collapse into one refresh this long after the last of them. +#define REPROBE_DEBOUNCE_NS ((uint64_t)U_TIME_1S_IN_NS) +//! Slow re-probe cadence while the fallback plug-in is the active one. +#define REPROBE_FALLBACK_PERIOD_NS ((uint64_t)10 * U_TIME_1S_IN_NS) +//! An adoption restart waits for this much continuous idleness, so a client +//! that is just reconnecting is not raced. +#define ADOPTION_RESTART_IDLE_NS ((uint64_t)2 * U_TIME_1S_IN_NS) + +static struct os_mutex g_reprobe_mutex; +static struct os_cond g_reprobe_cond; +static struct os_thread g_reprobe_thread; +static volatile bool g_reprobe_active = false; //!< worker accepting requests +static bool g_reprobe_stop = false; //!< guarded by g_reprobe_mutex +static bool g_reprobe_pending = false; //!< guarded by g_reprobe_mutex +static const char *g_reprobe_reason = NULL; //!< guarded by g_reprobe_mutex +static uint64_t g_reprobe_last_request_ns = 0; //!< guarded by g_reprobe_mutex + +static bool g_allow_adoption_restart = false; +static volatile bool g_restart_requested = false; + +void +ipc_server_request_display_reprobe(const char *reason) +{ + if (!g_reprobe_active) { + return; + } + os_mutex_lock(&g_reprobe_mutex); + g_reprobe_pending = true; + g_reprobe_reason = reason; + g_reprobe_last_request_ns = os_monotonic_get_ns(); + os_cond_signal(&g_reprobe_cond); + os_mutex_unlock(&g_reprobe_mutex); +} + +bool +ipc_server_restart_requested(void) +{ + return g_restart_requested; +} + +static void +reprobe_run(struct ipc_server *s, const char *reason) +{ + if (s->xsysc == NULL || s->xsysc->info.refresh_display_processors == NULL) { + return; + } + U_LOG_I("display re-probe (%s): re-evaluating display-processor selection.", reason != NULL ? reason : "?"); + const bool was_fallback = s->xsysc->info.active_plugin_is_fallback; + s->xsysc->info.refresh_display_processors(&s->xsysc->info); + if (was_fallback && !s->xsysc->info.active_plugin_is_fallback) { + U_LOG_W("display re-probe (%s): adopted plug-in '%s' (was the fallback).", + reason != NULL ? reason : "?", s->xsysc->info.active_plugin_id); + } +} + +static void * +reprobe_thread_func(void *ptr) +{ + struct ipc_server *s = (struct ipc_server *)ptr; + uint64_t last_fallback_ns = os_monotonic_get_ns(); + + os_mutex_lock(&g_reprobe_mutex); + while (!g_reprobe_stop) { + const uint64_t now = os_monotonic_get_ns(); + const char *reason = NULL; + + if (g_reprobe_pending) { + const uint64_t since = now - g_reprobe_last_request_ns; + if (since < REPROBE_DEBOUNCE_NS) { + // Still inside a burst: wait for it to settle. + os_cond_wait_timeout_ns(&g_reprobe_cond, &g_reprobe_mutex, REPROBE_DEBOUNCE_NS - since); + continue; + } + reason = g_reprobe_reason; + g_reprobe_pending = false; + } else if (s->xsysc != NULL && s->xsysc->info.active_plugin_is_fallback && + now - last_fallback_ns >= REPROBE_FALLBACK_PERIOD_NS) { + reason = "fallback timer"; + } + + if (reason == NULL) { + os_cond_wait_timeout_ns(&g_reprobe_cond, &g_reprobe_mutex, U_TIME_1S_IN_NS); + continue; + } + + os_mutex_unlock(&g_reprobe_mutex); + reprobe_run(s, reason); + last_fallback_ns = os_monotonic_get_ns(); + os_mutex_lock(&g_reprobe_mutex); + } + os_mutex_unlock(&g_reprobe_mutex); + return NULL; +} + +static void +reprobe_start(struct ipc_server *s) +{ + if (os_mutex_init(&g_reprobe_mutex) != 0) { + return; + } + if (os_cond_init(&g_reprobe_cond) != 0) { + os_mutex_destroy(&g_reprobe_mutex); + return; + } + g_reprobe_stop = false; + if (os_thread_init(&g_reprobe_thread) != 0 || + os_thread_start(&g_reprobe_thread, reprobe_thread_func, (void *)s) != 0) { + U_LOG_W( + "display re-probe worker could not start; only client events will re-probe display " + "selection."); + return; + } + os_thread_name(&g_reprobe_thread, "dxr-reprobe"); + g_reprobe_active = true; +} + +static void +reprobe_stop(void) +{ + if (!g_reprobe_active) { + return; + } + g_reprobe_active = false; + os_mutex_lock(&g_reprobe_mutex); + g_reprobe_stop = true; + os_cond_signal(&g_reprobe_cond); + os_mutex_unlock(&g_reprobe_mutex); + // Bounded by one in-flight refresh. The mutex/cond are intentionally not + // destroyed: a world-event source on another thread may still be between + // its g_reprobe_active check and the lock, and the process exits next. + os_thread_join(&g_reprobe_thread); + os_thread_destroy(&g_reprobe_thread); +} + +/*! + * Main-loop half of a complete adoption (ADR-045): a refresh adopted a better + * plug-in, but the system's head device still belongs to the previous one. + * Once no client has been connected for ADOPTION_RESTART_IDLE_NS, end the + * main loop and let the host start a successor whose system is built on the + * new plug-in. Clients connected meanwhile keep running on the weaving DP and + * display info that already switched; they pick up the complete system when + * they reconnect after the restart. + */ +static void +maybe_request_adoption_restart(struct ipc_server *s) +{ + if (s->xsysc == NULL || !s->xsysc->info.head_device_stale || g_restart_requested) { + return; + } + static uint64_t idle_since_ns = 0; + static bool warned_disallowed = false; + if (!g_allow_adoption_restart) { + if (!warned_disallowed) { + warned_disallowed = true; + U_LOG_W( + "plug-in adoption is partial (head device from the previous plug-in) and this instance " + "will not restart itself for it; restart the service for a complete adoption."); + } + return; + } + os_mutex_lock(&s->global_state.lock); + const uint32_t clients = s->global_state.connected_client_count; + os_mutex_unlock(&s->global_state.lock); + const uint64_t now = os_monotonic_get_ns(); + if (clients != 0) { + idle_since_ns = 0; + return; + } + if (idle_since_ns == 0) { + idle_since_ns = now; + return; + } + if (now - idle_since_ns < ADOPTION_RESTART_IDLE_NS) { + return; + } + U_LOG_W("plug-in adoption: no client connected — restarting the service so the head device follows '%s'.", + s->xsysc->info.active_plugin_id); + g_restart_requested = true; + s->running = false; +} +#endif // !XRT_OS_ANDROID + static int main_loop(struct ipc_server *s) { @@ -776,6 +983,11 @@ main_loop(struct ipc_server *s) // identified the panel yet; a no-op once it has. repull_display_info_if_unknown(s); +#ifndef XRT_OS_ANDROID + // ADR-045: complete a partial plug-in adoption by restarting when idle. + maybe_request_adoption_restart(s); +#endif + #ifdef XRT_OS_ANDROID // #1278: drive the visibility/weave-idle convergent pass from THIS // always-running 20 Hz loop. The multi main loop is parked for a pure @@ -1556,9 +1768,15 @@ ipc_server_main(int argc, char **argv, const struct ipc_server_main_info *ismi) // Print a very clear service started message. print_linux_end_user_started_information(log_level); #endif + // ADR-045 R-c: world-event re-probe worker (the service host feeds it). + g_allow_adoption_restart = ismi->allow_adoption_restart; + reprobe_start(s); + // Main loop. ret = main_loop(s); + reprobe_stop(); + // Stop the UI before tearing everything down. u_debug_gui_stop(&s->debug_gui); diff --git a/src/xrt/targets/cli/cli_query.c b/src/xrt/targets/cli/cli_query.c index db9869cd68..29d325bdf0 100644 --- a/src/xrt/targets/cli/cli_query.c +++ b/src/xrt/targets/cli/cli_query.c @@ -1296,6 +1296,144 @@ probe_vk_queue_lock_layer(struct cli_query_result *r) } #endif +/*! + * ADR-045: snapshot every registered plug-in's load outcome + platform state. + * Registered entries come from the discovery root (so a plug-in the loader + * never had to try still shows, as NOT_ATTEMPTED); the loader's records add + * what each attempt found. On Android, where enumeration is a stub, the + * loader's records alone are the list. + */ +static void +fill_plugin_states(struct cli_query_result *r) +{ + struct target_plugin_desc descs[16]; + struct target_plugin_status st[16]; + int nd = target_plugin_enumerate(descs, 16); + int ns = target_plugin_get_status(st, 16); + int n = 0; + + for (int i = 0; i < nd && n < 16; i++) { + struct target_plugin_status *o = &r->plugin_states[n++]; + bool found = false; + for (int j = 0; j < ns; j++) { + if (strcmp(st[j].id, descs[i].id) == 0) { + *o = st[j]; + found = true; + break; + } + } + if (!found) { + memset(o, 0, sizeof(*o)); + snprintf(o->id, sizeof(o->id), "%s", descs[i].id); + o->result = TARGET_PLUGIN_RESULT_NOT_ATTEMPTED; + o->fallback = strcmp(descs[i].id, "sim-display") == 0; + } + // The registration is the authority for name / version / order. + snprintf(o->display_name, sizeof(o->display_name), "%s", descs[i].display_name); + snprintf(o->version, sizeof(o->version), "%s", descs[i].version); + o->probe_order = descs[i].probe_order; + } + for (int j = 0; j < ns && n < 16; j++) { + bool listed = false; + for (int i = 0; i < nd; i++) { + if (strcmp(st[j].id, descs[i].id) == 0) { + listed = true; + break; + } + } + if (!listed) { + r->plugin_states[n++] = st[j]; + } + } + // Insertion sort by ProbeOrder (n <= 16). + for (int i = 1; i < n; i++) { + struct target_plugin_status tmp = r->plugin_states[i]; + int k = i - 1; + while (k >= 0 && r->plugin_states[k].probe_order > tmp.probe_order) { + r->plugin_states[k + 1] = r->plugin_states[k]; + k--; + } + r->plugin_states[k + 1] = tmp; + } + r->plugin_state_count = n; +} + +//! Find the ADR-045 record for @p id in @p r, or NULL. +static const struct target_plugin_status * +find_plugin_state(const struct cli_query_result *r, const char *id) +{ + for (int i = 0; i < r->plugin_state_count; i++) { + if (strcmp(r->plugin_states[i].id, id) == 0) { + return &r->plugin_states[i]; + } + } + return NULL; +} + +/*! + * One human line per registered plug-in, e.g. + * vendor plug-in 'Example 3D Display' 2.8.3 — PLATFORM_ABSENT: install the ... (DECLINED; id=..., ProbeOrder=50) + * Name and version are the registration's own; nothing vendor-specific here. + */ +static void +format_plugin_state_line(const struct target_plugin_status *p, char *out, size_t cap) +{ + const char *name = p->display_name[0] != '\0' ? p->display_name : p->id; + char state[192]; + if (p->platform_state == XRT_PLUGIN_PLATFORM_STATE_UNKNOWN) { + snprintf(state, sizeof(state), "UNKNOWN (platform state not reported)"); + } else { + snprintf(state, sizeof(state), "%s%s%s", target_plugin_platform_state_str(p->platform_state), + p->hint[0] != '\0' ? ": " : "", p->hint); + } + char detail[224]; + int used = snprintf(detail, sizeof(detail), "%s", target_plugin_load_result_str(p->result)); + if (used > 0 && (size_t)used < sizeof(detail) && p->reason[0] != '\0') { + used += snprintf(detail + used, sizeof(detail) - (size_t)used, ", %s", p->reason); + } else if (used > 0 && (size_t)used < sizeof(detail) && p->os_error != 0) { + used += snprintf(detail + used, sizeof(detail) - (size_t)used, ", err=%u", (unsigned)p->os_error); + } + snprintf(out, cap, "%s plug-in '%s'%s%s — %s (%s; id=%s, ProbeOrder=%u)", p->fallback ? "fallback" : "vendor", + name, p->version[0] != '\0' ? " " : "", p->version, state, detail, p->id, (unsigned)p->probe_order); +} + +static void +print_plugin_states_text(const struct cli_query_result *r) +{ + // P()/PT() are defined further down; spelled out here. + printf(" :: Registered display plug-ins (ADR-045 platform state)\n"); + if (r->plugin_state_count == 0) { + printf("\tnone registered\n"); + return; + } + for (int i = 0; i < r->plugin_state_count; i++) { + char line[640]; + format_plugin_state_line(&r->plugin_states[i], line, sizeof(line)); + printf("\t%s\n", line); + } +} + +static void +add_plugin_states_json(cJSON *root, const struct cli_query_result *r) +{ + cJSON *arr = cJSON_AddArrayToObject(root, "plugins"); + for (int i = 0; i < r->plugin_state_count; i++) { + const struct target_plugin_status *p = &r->plugin_states[i]; + cJSON *o = cJSON_CreateObject(); + cJSON_AddStringToObject(o, "id", p->id); + cJSON_AddStringToObject(o, "display_name", p->display_name); + cJSON_AddStringToObject(o, "version", p->version); + cJSON_AddNumberToObject(o, "probe_order", (double)p->probe_order); + cJSON_AddBoolToObject(o, "fallback", p->fallback); + cJSON_AddStringToObject(o, "load_result", target_plugin_load_result_str(p->result)); + cJSON_AddStringToObject(o, "platform_state", target_plugin_platform_state_str(p->platform_state)); + cJSON_AddStringToObject(o, "hint", p->hint); + cJSON_AddStringToObject(o, "reason", p->reason); + cJSON_AddNumberToObject(o, "os_error", (double)p->os_error); + cJSON_AddItemToArray(arr, o); + } +} + void cli_query_fill(struct cli_query_result *r, struct cli_query_handles *h, const struct xrt_instance_info *ii) { @@ -1336,6 +1474,8 @@ cli_query_fill(struct cli_query_result *r, struct cli_query_handles *h, const st r->instance_ok = true; xrt_result_t xret = xrt_instance_create_system(h->xi, &h->xsys, &h->xsysd, &h->xso, NULL); + // ADR-045: discovery has run (or failed) by now either way. + fill_plugin_states(r); if (xret != XRT_SUCCESS || h->xsysd == NULL) { r->result_code = CLI_SELFTEST_INIT_FAIL; return; @@ -1396,10 +1536,19 @@ cli_query_fill(struct cli_query_result *r, struct cli_query_handles *h, const st "no better-ranked plug-in failed to load (active ProbeOrder=%u, %d declined their probe)", (unsigned)disc.active_probe_order, disc.declined_count); } else { + // ADR-045: add what the rejected plug-in said about its platform + // (UNKNOWN when it never got far enough to be asked). + const struct target_plugin_status *rp = find_plugin_state(r, disc.best_rejected_id); + char pstate[160] = ""; + if (rp != NULL) { + snprintf(pstate, sizeof(pstate), " [platform %s%s%s]", + target_plugin_platform_state_str(rp->platform_state), rp->hint[0] != '\0' ? ": " : "", + rp->hint); + } snprintf(r->vendor_dp_note, sizeof(r->vendor_dp_note), - "'%s' (ProbeOrder=%u) out-ranks the active plug-in but was rejected: %s — the runtime " + "'%s' (ProbeOrder=%u) out-ranks the active plug-in but was rejected: %s%s — the runtime " "fell back to '%s'", - disc.best_rejected_id, (unsigned)disc.best_rejected_order, disc.best_rejected_reason, + disc.best_rejected_id, (unsigned)disc.best_rejected_order, disc.best_rejected_reason, pstate, iface->id ? iface->id : "?"); } @@ -1771,6 +1920,8 @@ cli_query_print_info_text(const struct cli_query_result *r) print_x11_scale_text(r); + print_plugin_states_text(r); + P(" :: Display processor\n"); if (!r->head_ok) { PT("No display processor discovered.\n"); @@ -2019,6 +2170,8 @@ cli_query_info_to_cjson(const struct cli_query_result *r) } } + add_plugin_states_json(root, r); + if (r->plugin_ok) { cJSON *pl = cJSON_AddObjectToObject(root, "plugin"); cJSON_AddStringToObject(pl, "id", r->plugin_id); @@ -2592,6 +2745,9 @@ cli_query_print_selftest_text(const struct cli_query_result *r) P("%s: %s — %s\n", checks[i].ok ? "PASS" : "FAIL", checks[i].name, checks[i].detail); } + // ADR-045: informational — never changes the verdict or the exit code. + print_plugin_states_text(r); + if (r->result_code == CLI_SELFTEST_PASS) { P(" :: SELF-TEST PASSED\n"); } else { @@ -2616,6 +2772,7 @@ cli_query_selftest_to_cjson(const struct cli_query_result *r) } cJSON_AddStringToObject(root, "dpi_awareness", r->dpi_awareness); + add_plugin_states_json(root, r); cJSON_AddStringToObject(root, "verdict", r->result_code == CLI_SELFTEST_PASS ? "PASS" : "FAIL"); cJSON_AddNumberToObject(root, "result_code", (double)r->result_code); diff --git a/src/xrt/targets/cli/cli_query.h b/src/xrt/targets/cli/cli_query.h index 3bbf724c27..44565dd558 100644 --- a/src/xrt/targets/cli/cli_query.h +++ b/src/xrt/targets/cli/cli_query.h @@ -19,6 +19,7 @@ #include "cli_dims_check.h" #include "xrt/xrt_plugin.h" +#include "target_plugin_loader.h" // ADR-045 per-plug-in status #include "os/os_display_desktop.h" #include "os/os_display_scale.h" #include "xrt/xrt_device.h" @@ -196,6 +197,13 @@ struct cli_query_result bool vendor_dp_ok; char vendor_dp_note[256]; + /* ADR-045 — every registered plug-in with its load outcome and the + * platform state + hint it reports (UNKNOWN for a plug-in that predates + * the get_platform_state slot). Sorted by ProbeOrder. Valid once the + * instance was created; count 0 otherwise. */ + int plugin_state_count; + struct target_plugin_status plugin_states[16]; + /* Head/display device description (valid iff head_ok). */ char head_str[256]; diff --git a/src/xrt/targets/common/target_instance.c b/src/xrt/targets/common/target_instance.c index 461def0350..c8779b399f 100644 --- a/src/xrt/targets/common/target_instance.c +++ b/src/xrt/targets/common/target_instance.c @@ -116,6 +116,8 @@ fill_dp_factories_from_plugin(struct xrt_system_compositor_info *info, const str if (plugin->id != NULL) { snprintf(info->active_plugin_id, sizeof(info->active_plugin_id), "%s", plugin->id); } + // ADR-045: drives the service's slow fallback re-probe timer. + info->active_plugin_is_fallback = target_plugin_iface_is_fallback(plugin); // #1243/#1244: this is the path Android takes, and it was NOT covered by the // original guard — a config-skewed plug-in reached the compositor here and // faulted inside the Adreno driver instead of being refused. Same check as @@ -464,6 +466,21 @@ refresh_display_processors_cb(struct xrt_system_compositor_info *info) const struct xrt_plugin_iface *before = target_plugin_get_active(); const struct xrt_plugin_iface *plugin = target_plugin_refresh_active(); const bool swapped = plugin != before; + if (swapped && before != NULL && !info->head_device_stale) { + // ADR-045 complete adoption: the weaving DP and display info switch + // below, but the head device the system was built on (its rendering + // modes, eye tracking, pose binding) belongs to the previous plug-in + // and is referenced by the system compositor and every client's + // shared-memory snapshot — it cannot be replaced under them. Flag it; + // the service restarts once it is idle so the next client gets a + // system built on the new plug-in end to end. + info->head_device_stale = true; + U_LOG_W( + "plug-in adoption: '%s' -> '%s' — weaving DP and display info follow now; the head device (modes, " + "eye tracking) is still the previous plug-in's until the service restarts (when no client is " + "connected).", + before->id ? before->id : "?", plugin->id ? plugin->id : "?"); + } fill_dp_factories_from_plugin(info, plugin); const enum display_info_refresh_result refreshed = refresh_display_info_from_plugin(info, plugin); // #1721/#1722: while the panel is still unidentified this runs once a diff --git a/src/xrt/targets/common/target_plugin_loader.c b/src/xrt/targets/common/target_plugin_loader.c index 60624fb250..a23d9821e6 100644 --- a/src/xrt/targets/common/target_plugin_loader.c +++ b/src/xrt/targets/common/target_plugin_loader.c @@ -270,6 +270,293 @@ target_plugin_get_discovery_summary(struct target_plugin_discovery_summary *out) snprintf(out->best_rejected_reason, sizeof(out->best_rejected_reason), "%s", g_best_rejected_reason); } +/* + * + * Per-plug-in status records + platform state (ADR-045). + * + */ + +#define TARGET_PLUGIN_MAX_STATUS 16 + +/*! + * One record per registered plug-in the loader has attempted (or skipped) + * this process, keyed by id. Written by every load attempt — first discovery, + * each refresh, the display-claim collection — and read by diagnostics + * (`displayxr-cli`, the service tray) from other threads, so it has its own + * small mutex rather than @ref g_refresh_mutex (a refresh can hold that one + * for as long as a slow plug-in probe takes). + */ +static struct target_plugin_status g_status[TARGET_PLUGIN_MAX_STATUS]; +static int g_status_count = 0; +//! Per record: a load of this id has been attempted at least once (the #434 +//! breadcrumb and the #461 skew check WARN only on the first attempt). +static bool g_status_attempted[TARGET_PLUGIN_MAX_STATUS]; +static struct os_mutex g_status_mutex; +static int g_status_mutex_initialized = 0; + +static void +status_lock(void) +{ + if (g_status_mutex_initialized) { + os_mutex_lock(&g_status_mutex); + } +} + +static void +status_unlock(void) +{ + if (g_status_mutex_initialized) { + os_mutex_unlock(&g_status_mutex); + } +} + +//! Find or add the record for @p id. Caller holds the status lock. -1 if full. +static int +status_find_or_add_locked(const char *id) +{ + for (int i = 0; i < g_status_count; i++) { + if (strcmp(g_status[i].id, id) == 0) { + return i; + } + } + if (g_status_count >= TARGET_PLUGIN_MAX_STATUS) { + return -1; + } + int i = g_status_count++; + memset(&g_status[i], 0, sizeof(g_status[i])); + g_status_attempted[i] = false; + snprintf(g_status[i].id, sizeof(g_status[i].id), "%s", id); + return i; +} + +/*! + * Start an attempt at one registered plug-in: refresh its identity fields from + * the discovery root. Returns true if this is the FIRST attempt at this id in + * this process (callers keep their one-shot WARNs to that attempt). + */ +static bool +status_begin(const char *id, const char *display_name, const char *version, uint32_t probe_order) +{ + bool first = true; + status_lock(); + int i = status_find_or_add_locked(id != NULL ? id : "?"); + if (i >= 0) { + struct target_plugin_status *s = &g_status[i]; + snprintf(s->display_name, sizeof(s->display_name), "%s", display_name != NULL ? display_name : ""); + snprintf(s->version, sizeof(s->version), "%s", version != NULL ? version : ""); + s->probe_order = probe_order; + first = !g_status_attempted[i]; + g_status_attempted[i] = true; + } + status_unlock(); + return first; +} + +/*! + * Record the outcome of an attempt. Returns true when it DIFFERS from the + * previous outcome recorded for this id (or is the first), so a caller can log + * a repeated failure at INFO instead of WARN: the service re-probes on world + * events and on a slow timer while the fallback is active, and a vendor + * plug-in whose platform is absent must cost one WARN per process, not one per + * retry. + */ +static bool +status_finish(const char *id, enum target_plugin_load_result result, uint32_t os_error, const char *reason) +{ + bool changed = true; + status_lock(); + int i = status_find_or_add_locked(id != NULL ? id : "?"); + if (i >= 0) { + struct target_plugin_status *s = &g_status[i]; + changed = !(s->result == result && s->os_error == os_error); + s->result = result; + s->os_error = os_error; + snprintf(s->reason, sizeof(s->reason), "%s", reason != NULL ? reason : ""); + if (result == TARGET_PLUGIN_RESULT_BINARY_MISSING || + result == TARGET_PLUGIN_RESULT_DEPENDENCY_MISSING || result == TARGET_PLUGIN_RESULT_LOAD_FAILED || + result == TARGET_PLUGIN_RESULT_PATH_REFUSED || result == TARGET_PLUGIN_RESULT_NO_ENTRY_POINT || + result == TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED || result == TARGET_PLUGIN_RESULT_ABI_MISMATCH) { + // Never got far enough to ask: a stale state from an + // earlier attempt would be a lie. + s->platform_state = XRT_PLUGIN_PLATFORM_STATE_UNKNOWN; + s->platform_flags = 0; + s->hint[0] = '\0'; + } + } + status_unlock(); + return changed; +} + +//! Mark @p id as the active plug-in; any previously ACTIVE record becomes CLAIMED. +static void +status_set_active(const char *id) +{ + status_lock(); + for (int i = 0; i < g_status_count; i++) { + if (g_status[i].result == TARGET_PLUGIN_RESULT_ACTIVE) { + g_status[i].result = TARGET_PLUGIN_RESULT_CLAIMED; + } + } + int i = status_find_or_add_locked(id != NULL ? id : "?"); + if (i >= 0) { + g_status[i].result = TARGET_PLUGIN_RESULT_ACTIVE; + } + status_unlock(); +} + +/*! + * Ask @p iface for its platform state through the struct_size-gated optional + * slot. Always fills @p out (UNKNOWN when not reported). + */ +static bool +query_platform_state(const struct xrt_plugin_iface *iface, struct xrt_plugin_platform_status *out) +{ + memset(out, 0, sizeof(*out)); + out->struct_size = (uint32_t)sizeof(*out); + if (iface == NULL || + iface->struct_size < + offsetof(struct xrt_plugin_iface, get_platform_state) + sizeof(iface->get_platform_state) || + iface->get_platform_state == NULL) { + return false; + } + if (!iface->get_platform_state(out)) { + memset(out, 0, sizeof(*out)); + out->struct_size = (uint32_t)sizeof(*out); + return false; + } + out->hint[sizeof(out->hint) - 1] = '\0'; + if (out->state > XRT_PLUGIN_PLATFORM_STATE_INCOMPATIBLE) { + out->state = XRT_PLUGIN_PLATFORM_STATE_UNKNOWN; + } + return true; +} + +/*! + * Load-path hook, called after negotiate + the ABI gate and BEFORE probe(): + * record what the plug-in says about its platform, so a plug-in that is about + * to decline can still say why. Logs only on a change of state. + */ +static void +status_record_platform_state(const char *id, const struct xrt_plugin_iface *iface) +{ + struct xrt_plugin_platform_status ps; + bool reported = query_platform_state(iface, &ps); + bool changed = false; + status_lock(); + int i = status_find_or_add_locked(id != NULL ? id : "?"); + if (i >= 0) { + struct target_plugin_status *s = &g_status[i]; + changed = s->platform_state != ps.state || strcmp(s->hint, ps.hint) != 0; + s->platform_state = ps.state; + s->platform_flags = ps.flags; + snprintf(s->hint, sizeof(s->hint), "%s", ps.hint); + } + status_unlock(); + if (reported && changed) { + U_LOG_W("plugin loader: %s: platform state %s%s%s", id != NULL ? id : "?", + target_plugin_platform_state_str(ps.state), ps.hint[0] != '\0' ? " — " : "", ps.hint); + } +} + +const char * +target_plugin_load_result_str(enum target_plugin_load_result r) +{ + switch (r) { + case TARGET_PLUGIN_RESULT_NOT_ATTEMPTED: return "NOT_ATTEMPTED"; + case TARGET_PLUGIN_RESULT_ACTIVE: return "ACTIVE"; + case TARGET_PLUGIN_RESULT_CLAIMED: return "LOADED"; + case TARGET_PLUGIN_RESULT_DECLINED: return "DECLINED"; + case TARGET_PLUGIN_RESULT_BINARY_MISSING: return "BINARY_MISSING"; + case TARGET_PLUGIN_RESULT_DEPENDENCY_MISSING: return "DEPENDENCY_MISSING"; + case TARGET_PLUGIN_RESULT_LOAD_FAILED: return "LOAD_FAILED"; + case TARGET_PLUGIN_RESULT_PATH_REFUSED: return "PATH_REFUSED"; + case TARGET_PLUGIN_RESULT_NO_ENTRY_POINT: return "NO_ENTRY_POINT"; + case TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED: return "NEGOTIATE_FAILED"; + case TARGET_PLUGIN_RESULT_ABI_MISMATCH: return "ABI_MISMATCH"; + case TARGET_PLUGIN_RESULT_PROBE_FAILED: return "PROBE_FAILED"; + } + return "?"; +} + +const char * +target_plugin_platform_state_str(uint32_t state) +{ + switch (state) { + case XRT_PLUGIN_PLATFORM_STATE_UNKNOWN: return "UNKNOWN"; + case XRT_PLUGIN_PLATFORM_STATE_READY: return "READY"; + case XRT_PLUGIN_PLATFORM_STATE_PLATFORM_ABSENT: return "PLATFORM_ABSENT"; + case XRT_PLUGIN_PLATFORM_STATE_PLATFORM_NOT_RUNNING: return "PLATFORM_NOT_RUNNING"; + case XRT_PLUGIN_PLATFORM_STATE_NO_DISPLAY: return "NO_DISPLAY"; + case XRT_PLUGIN_PLATFORM_STATE_INCOMPATIBLE: return "INCOMPATIBLE"; + default: return "UNKNOWN"; + } +} + +bool +target_plugin_iface_is_fallback(const struct xrt_plugin_iface *iface) +{ + if (iface == NULL) { + return false; + } + struct xrt_plugin_platform_status ps; + if (query_platform_state(iface, &ps)) { + return (ps.flags & XRT_PLUGIN_PLATFORM_FLAG_FALLBACK) != 0; + } + // A plug-in too old to report state: only the runtime's OWN simulation + // plug-in is a fallback. Never a vendor id. + return iface->id != NULL && strcmp(iface->id, "sim-display") == 0; +} + +bool +target_plugin_query_active_platform_state(struct xrt_plugin_platform_status *out) +{ + if (out == NULL) { + return false; + } + return query_platform_state(g_active_iface, out); +} + +int +target_plugin_get_status(struct target_plugin_status *out, int max) +{ + if (out == NULL || max <= 0) { + return 0; + } + const struct xrt_plugin_iface *active = g_active_iface; + struct xrt_plugin_platform_status live; + bool live_ok = query_platform_state(active, &live); + + status_lock(); + int n = g_status_count < max ? g_status_count : max; + for (int i = 0; i < n; i++) { + out[i] = g_status[i]; + if (live_ok && out[i].result == TARGET_PLUGIN_RESULT_ACTIVE && active != NULL && active->id != NULL && + strcmp(active->id, out[i].id) == 0) { + out[i].platform_state = live.state; + out[i].platform_flags = live.flags; + snprintf(out[i].hint, sizeof(out[i].hint), "%s", live.hint); + } + out[i].fallback = (out[i].platform_flags & XRT_PLUGIN_PLATFORM_FLAG_FALLBACK) != 0 || + (out[i].platform_state == XRT_PLUGIN_PLATFORM_STATE_UNKNOWN && + strcmp(out[i].id, "sim-display") == 0); + } + status_unlock(); + return n; +} + +/*! + * Log a load-path line at WARN the first time an outcome is seen for a + * plug-in, at INFO when it merely repeats (re-probes, ADR-045). + */ +#define PLUGIN_LOG_OUTCOME(changed, ...) \ + do { \ + if (changed) { \ + U_LOG_W(__VA_ARGS__); \ + } else { \ + U_LOG_I(__VA_ARGS__); \ + } \ + } while (false) + /*! * Max plug-in sources consulted when building the per-display registry * (issue #69 / ADR-015). One per registered plug-in — a handful in practice. @@ -578,9 +865,34 @@ load_and_probe_one(const struct plugin_entry *e, *out_version = 0; } + // Per-attempt reject context, consumed by plugin_note_reject. Reset here + // so an attempt from the display-claim collection (which never consumes + // it) cannot leak a stale reason / "declined" mark into discovery. + g_last_reject_reason[0] = '\0'; + g_last_reject_declined = false; + + const bool first_attempt = status_begin(e->id, e->display_name, e->version, e->probe_order); + + // ADR-045: an orphan registration (Binary deleted, key left behind) is a + // fact to skip, not an error to repeat — one WARN per process. + if (GetFileAttributesW(e->binary_path) == INVALID_FILE_ATTRIBUTES) { + DWORD gle = GetLastError(); + if (gle == ERROR_FILE_NOT_FOUND || gle == ERROR_PATH_NOT_FOUND) { + bool changed = status_finish(e->id, TARGET_PLUGIN_RESULT_BINARY_MISSING, (uint32_t)gle, NULL); + PLUGIN_LOG_OUTCOME(changed, + "plugin loader: %s: registered Binary '%ls' does not exist — skipping " + "(orphan registration).", + e->id, e->binary_path); + snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), + "registered Binary does not exist (orphan registration)"); + return NULL; + } + } + // #952: refuse a build-tree/worktree DLL path (the #943 footgun) unless // DXR_ALLOW_DEV_PLUGIN_PATHS is set; warn on other non-install paths. if (target_plugin_path_check(e->binary_path, e->id, "plugin") == TARGET_PLUGIN_PATH_REFUSED) { + status_finish(e->id, TARGET_PLUGIN_RESULT_PATH_REFUSED, 0, "dev/build-tree path refused (#952)"); return NULL; } @@ -591,18 +903,35 @@ load_and_probe_one(const struct plugin_entry *e, // One-shot breadcrumb: a host-side crash during the load below (DLL // notification callbacks run host code) leaves this as the last line - // in the per-app log, naming the in-flight binary (issue #434). - U_LOG_W("plugin loader: %s: loading plug-in binary %ls", e->id, e->binary_path); + // in the per-app log, naming the in-flight binary (issue #434). WARN on + // the first attempt per process; re-probes (ADR-045) log it at INFO. + PLUGIN_LOG_OUTCOME(first_attempt, "plugin loader: %s: loading plug-in binary %ls", e->id, e->binary_path); // #461: warn if the registry-declared version doesn't match the DLL on // disk (an installer skipped a locked file). Diagnostic only — the ABI // negotiation below remains the actual compatibility gate. - check_registry_dll_version_skew(e); + if (first_attempt) { + check_registry_dll_version_skew(e); + } HMODULE dll = LoadLibraryExW(e->binary_path, NULL, LOAD_WITH_ALTERED_SEARCH_PATH); if (dll == NULL) { - U_LOG_W("plugin loader: %s: LoadLibrary(%ls) failed (err=%lu).", e->id, e->binary_path, - GetLastError()); + DWORD gle = GetLastError(); + // The binary exists (checked above), so ERROR_MOD_NOT_FOUND means a + // library IT imports is missing — typically the vendor platform the + // plug-in should resolve lazily (ADR-045 rule 1). + const bool dep = gle == ERROR_MOD_NOT_FOUND; + bool changed = status_finish( + e->id, dep ? TARGET_PLUGIN_RESULT_DEPENDENCY_MISSING : TARGET_PLUGIN_RESULT_LOAD_FAILED, + (uint32_t)gle, NULL); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: LoadLibrary(%ls) failed (err=%lu)%s.", e->id, + e->binary_path, gle, + dep ? " — a library the plug-in imports is missing (vendor platform not installed, " + "or not resolvable from this process)" + : ""); + snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "%s (err=%lu)", + dep ? "dependency missing: a library the plug-in imports was not found" : "LoadLibrary failed", + gle); return NULL; } @@ -611,6 +940,7 @@ load_and_probe_one(const struct plugin_entry *e, if (negotiate == NULL) { U_LOG_W("plugin loader: %s: missing entry point '%s' — skipping.", e->id, XRT_PLUGIN_ENTRYPOINT_NAME); + status_finish(e->id, TARGET_PLUGIN_RESULT_NO_ENTRY_POINT, 0, NULL); FreeLibrary(dll); return NULL; } @@ -638,6 +968,7 @@ load_and_probe_one(const struct plugin_entry *e, if (xret != XRT_SUCCESS || iface == NULL) { U_LOG_W("plugin loader: %s: negotiate returned %d (iface=%p) — skipping.", e->id, (int)xret, (void *)iface); + status_finish(e->id, TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED, 0, NULL); FreeLibrary(dll); return NULL; } @@ -656,14 +987,19 @@ load_and_probe_one(const struct plugin_entry *e, snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "ABI mismatch: plug-in reports v%u, runtime expects v%u (rebuild it)", plugin_version, (unsigned)XRT_PLUGIN_API_VERSION_CURRENT); + status_finish(e->id, TARGET_PLUGIN_RESULT_ABI_MISMATCH, 0, g_last_reject_reason); FreeLibrary(dll); return NULL; } + // ADR-045: ask BEFORE probe(), so a plug-in about to decline can say why. + status_record_platform_state(e->id, iface); + if (iface->probe != NULL) { xret = iface->probe(out_inst); if (xret == XRT_ERROR_PROBER_NOT_SUPPORTED) { U_LOG_I("plugin loader: %s: probe declined (no matching device).", e->id); + status_finish(e->id, TARGET_PLUGIN_RESULT_DECLINED, 0, NULL); /* #1212: a plug-in that LOADED and then said "not my * hardware" is behaving correctly on a box without that * panel. Only a failed LOAD is a misconfiguration, so @@ -674,7 +1010,9 @@ load_and_probe_one(const struct plugin_entry *e, return NULL; } if (xret != XRT_SUCCESS) { - U_LOG_W("plugin loader: %s: probe returned %d — skipping.", e->id, (int)xret); + bool changed = status_finish(e->id, TARGET_PLUGIN_RESULT_PROBE_FAILED, (uint32_t)xret, NULL); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: probe returned %d — skipping.", e->id, + (int)xret); FreeLibrary(dll); return NULL; } @@ -683,6 +1021,7 @@ load_and_probe_one(const struct plugin_entry *e, if (out_version != NULL) { *out_version = plugin_version; } + status_finish(e->id, TARGET_PLUGIN_RESULT_CLAIMED, 0, NULL); return iface; } @@ -706,6 +1045,7 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst iface->id ? iface->id : e->id, iface->display_name ? iface->display_name : e->display_name, iface->vendor ? iface->vendor : e->vendor, e->version, plugin_version, e->probe_order, e->binary_path); + status_set_active(e->id); return iface; } @@ -1680,12 +2020,23 @@ static const struct xrt_plugin_iface * try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst) { *out_inst = NULL; + g_last_reject_reason[0] = '\0'; + g_last_reject_declined = false; + (void)status_begin(e->id, NULL, NULL, e->probe_order); /* RTLD_LOCAL keeps the plug-in's symbols private; aux symbols * resolve via the runtime .so already in the namespace. */ void *handle = dlopen(e->binary_path, RTLD_NOW | RTLD_LOCAL); if (handle == NULL) { - U_LOG_W("plugin loader: %s: dlopen(%s) failed: %s.", e->id, e->binary_path, dlerror()); + const char *dl_err = dlerror(); + struct stat st; + const bool missing = stat(e->binary_path, &st) != 0; + bool changed = status_finish( + e->id, missing ? TARGET_PLUGIN_RESULT_BINARY_MISSING : TARGET_PLUGIN_RESULT_LOAD_FAILED, 0, dl_err); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: dlopen(%s) failed: %s.", e->id, e->binary_path, + dl_err != NULL ? dl_err : "?"); + snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "%s", + missing ? "registered binary does not exist (orphan registration)" : "dlopen failed"); return NULL; } @@ -1696,6 +2047,7 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst if (negotiate == NULL || err != NULL) { U_LOG_W("plugin loader: %s: missing entry point '%s' (%s) — skipping.", e->id, XRT_PLUGIN_ENTRYPOINT_NAME, err ? err : "null"); + status_finish(e->id, TARGET_PLUGIN_RESULT_NO_ENTRY_POINT, 0, NULL); dlclose(handle); return NULL; } @@ -1723,6 +2075,7 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst if (xret != XRT_SUCCESS || iface == NULL) { U_LOG_W("plugin loader: %s: negotiate returned %d (iface=%p) — skipping.", e->id, (int)xret, (void *)iface); + status_finish(e->id, TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED, 0, NULL); dlclose(handle); return NULL; } @@ -1741,10 +2094,14 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "ABI mismatch: plug-in reports v%u, runtime expects v%u (rebuild it)", plugin_version, (unsigned)XRT_PLUGIN_API_VERSION_CURRENT); + status_finish(e->id, TARGET_PLUGIN_RESULT_ABI_MISMATCH, 0, g_last_reject_reason); dlclose(handle); return NULL; } + // ADR-045: ask BEFORE probe(), so a plug-in about to decline can say why. + status_record_platform_state(e->id, iface); + if (iface->probe != NULL) { xret = iface->probe(out_inst); if (xret == XRT_ERROR_PROBER_NOT_SUPPORTED) { @@ -1755,11 +2112,14 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst * mark this so the vendor_dp self-test does not fail a * dev box that merely has a vendor plug-in registered. */ g_last_reject_declined = true; + status_finish(e->id, TARGET_PLUGIN_RESULT_DECLINED, 0, NULL); dlclose(handle); return NULL; } if (xret != XRT_SUCCESS) { - U_LOG_W("plugin loader: %s: probe returned %d — skipping.", e->id, (int)xret); + bool changed = status_finish(e->id, TARGET_PLUGIN_RESULT_PROBE_FAILED, (uint32_t)xret, NULL); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: probe returned %d — skipping.", e->id, + (int)xret); dlclose(handle); return NULL; } @@ -1772,6 +2132,8 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst iface->vendor ? iface->vendor : "", iface->version ? iface->version : "", plugin_version, e->probe_order, e->binary_path); + status_set_active(e->id); + /* dlopen handle intentionally leaked: the iface's function pointers * remain reachable into the .so for the process's lifetime. */ return iface; @@ -2240,13 +2602,24 @@ static const struct xrt_plugin_iface * try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst) { *out_inst = NULL; + g_last_reject_reason[0] = '\0'; + g_last_reject_declined = false; + (void)status_begin(e->id, e->display_name, e->version, e->probe_order); /* RTLD_LOCAL keeps the plug-in's symbols private; aux symbols * resolve via the dependent runtime dylib that ld already linked * into our process. */ void *handle = dlopen(e->binary_path, RTLD_NOW | RTLD_LOCAL); if (handle == NULL) { - U_LOG_W("plugin loader: %s: dlopen(%s) failed: %s.", e->id, e->binary_path, dlerror()); + const char *dl_err = dlerror(); + struct stat st; + const bool missing = stat(e->binary_path, &st) != 0; + bool changed = status_finish( + e->id, missing ? TARGET_PLUGIN_RESULT_BINARY_MISSING : TARGET_PLUGIN_RESULT_LOAD_FAILED, 0, dl_err); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: dlopen(%s) failed: %s.", e->id, e->binary_path, + dl_err != NULL ? dl_err : "?"); + snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "%s", + missing ? "registered binary does not exist (orphan registration)" : "dlopen failed"); return NULL; } @@ -2257,6 +2630,7 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst if (negotiate == NULL || err != NULL) { U_LOG_W("plugin loader: %s: missing entry point '%s' (%s) — skipping.", e->id, XRT_PLUGIN_ENTRYPOINT_NAME, err ? err : "null"); + status_finish(e->id, TARGET_PLUGIN_RESULT_NO_ENTRY_POINT, 0, NULL); dlclose(handle); return NULL; } @@ -2284,6 +2658,7 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst if (xret != XRT_SUCCESS || iface == NULL) { U_LOG_W("plugin loader: %s: negotiate returned %d (iface=%p) — skipping.", e->id, (int)xret, (void *)iface); + status_finish(e->id, TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED, 0, NULL); dlclose(handle); return NULL; } @@ -2302,10 +2677,14 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst snprintf(g_last_reject_reason, sizeof(g_last_reject_reason), "ABI mismatch: plug-in reports v%u, runtime expects v%u (rebuild it)", plugin_version, (unsigned)XRT_PLUGIN_API_VERSION_CURRENT); + status_finish(e->id, TARGET_PLUGIN_RESULT_ABI_MISMATCH, 0, g_last_reject_reason); dlclose(handle); return NULL; } + // ADR-045: ask BEFORE probe(), so a plug-in about to decline can say why. + status_record_platform_state(e->id, iface); + if (iface->probe != NULL) { xret = iface->probe(out_inst); if (xret == XRT_ERROR_PROBER_NOT_SUPPORTED) { @@ -2316,11 +2695,14 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst * mark this so the vendor_dp self-test does not fail a * dev box that merely has a vendor plug-in registered. */ g_last_reject_declined = true; + status_finish(e->id, TARGET_PLUGIN_RESULT_DECLINED, 0, NULL); dlclose(handle); return NULL; } if (xret != XRT_SUCCESS) { - U_LOG_W("plugin loader: %s: probe returned %d — skipping.", e->id, (int)xret); + bool changed = status_finish(e->id, TARGET_PLUGIN_RESULT_PROBE_FAILED, (uint32_t)xret, NULL); + PLUGIN_LOG_OUTCOME(changed, "plugin loader: %s: probe returned %d — skipping.", e->id, + (int)xret); dlclose(handle); return NULL; } @@ -2332,6 +2714,8 @@ try_load_one(const struct plugin_entry *e, struct xrt_plugin_instance **out_inst iface->id ? iface->id : e->id, iface->display_name ? iface->display_name : e->display_name, iface->vendor ? iface->vendor : e->vendor, e->version, plugin_version, e->probe_order, e->binary_path); + status_set_active(e->id); + /* dlopen handle intentionally leaked: the iface's function pointers * remain reachable into the dylib for the process's lifetime. */ return iface; @@ -2581,6 +2965,10 @@ target_plugin_get_active(void) U_LOG_W("plugin loader: os_mutex_init failed — refresh path will skip locking."); } } + // ADR-045 status records are read from diagnostics threads (service tray). + if (!g_status_mutex_initialized && os_mutex_init(&g_status_mutex) == 0) { + g_status_mutex_initialized = 1; + } g_active_iface = discover_active_plugin(&g_active_instance, 0xFFFFFFFFu /* try all */); return g_active_iface; @@ -2618,6 +3006,15 @@ target_plugin_refresh_active(void) return g_active_iface; } + // ADR-045 D3 — no live swap. Re-selection exists to get OFF the fallback + // (a vendor plug-in registered, or its platform came up, after this + // process selected the fallback). A non-fallback active plug-in is kept + // even when it reports NO_DISPLAY: diagnostics surface that state and its + // DP passes pixels through, and nothing is re-probed here. + if (g_active_iface != NULL && !target_plugin_iface_is_fallback(g_active_iface)) { + return g_active_iface; + } + if (g_refresh_mutex_initialized) { os_mutex_lock(&g_refresh_mutex); } diff --git a/src/xrt/targets/common/target_plugin_loader.h b/src/xrt/targets/common/target_plugin_loader.h index a1b246dc26..c1f225d6ef 100644 --- a/src/xrt/targets/common/target_plugin_loader.h +++ b/src/xrt/targets/common/target_plugin_loader.h @@ -186,6 +186,88 @@ struct target_plugin_discovery_summary void target_plugin_get_discovery_summary(struct target_plugin_discovery_summary *out); +/*! + * Outcome of the loader's most recent attempt at one registered plug-in + * (ADR-045). Recorded per entry so diagnostics can say WHY a plug-in is not + * the active one without re-loading anything. + */ +enum target_plugin_load_result +{ + TARGET_PLUGIN_RESULT_NOT_ATTEMPTED = 0, //!< a better-ranked plug-in won first + TARGET_PLUGIN_RESULT_ACTIVE, //!< the active plug-in + TARGET_PLUGIN_RESULT_CLAIMED, //!< probe succeeded; loaded, not the active one + TARGET_PLUGIN_RESULT_DECLINED, //!< loaded, probe declined (not my hardware / platform) + TARGET_PLUGIN_RESULT_BINARY_MISSING, //!< registered Binary does not exist (orphan entry) + TARGET_PLUGIN_RESULT_DEPENDENCY_MISSING, //!< binary exists, a library it imports does not + TARGET_PLUGIN_RESULT_LOAD_FAILED, //!< any other load failure + TARGET_PLUGIN_RESULT_PATH_REFUSED, //!< dev/build-tree path refused (#952) + TARGET_PLUGIN_RESULT_NO_ENTRY_POINT, //!< no xrtPluginNegotiate export + TARGET_PLUGIN_RESULT_NEGOTIATE_FAILED, //!< negotiate returned an error + TARGET_PLUGIN_RESULT_ABI_MISMATCH, //!< ADR-020 rule 3 reject + TARGET_PLUGIN_RESULT_PROBE_FAILED, //!< probe returned a hard error +}; + +/*! + * Per-registered-plug-in record kept by the loader (ADR-045): the last load + * outcome plus the platform state the plug-in reported through + * `xrt_plugin_iface::get_platform_state` at that attempt. All strings UTF-8. + */ +struct target_plugin_status +{ + char id[64]; + char display_name[128]; //!< discovery-root DisplayName (never hardcoded) + char version[64]; //!< discovery-root Version + uint32_t probe_order; + enum target_plugin_load_result result; + //! OS error of a failed load (Win32 error code), 0 if none. + uint32_t os_error; + //! @ref xrt_plugin_platform_state; UNKNOWN when not reported. + uint32_t platform_state; + //! `XRT_PLUGIN_PLATFORM_FLAG_*`. + uint32_t platform_flags; + //! Vendor-written hint, shown verbatim. + char hint[XRT_PLUGIN_PLATFORM_HINT_MAX]; + //! Loader-written detail for a failure (ABI text, dlerror, ...); "" if none. + char reason[160]; + //! A FALLBACK plug-in (same rule as @ref target_plugin_iface_is_fallback). + bool fallback; +}; + +/*! + * Copy the loader's per-plug-in records (one per registered plug-in it has + * seen this process, in first-seen order) into @p out; returns the count. + * The active plug-in's platform state is re-queried live (the slot is cheap + * and thread-safe by contract), so a vendor plug-in that later reports + * NO_DISPLAY shows it. Safe from any thread; never loads anything. + */ +int +target_plugin_get_status(struct target_plugin_status *out, int max); + +//! Short stable name for a @ref target_plugin_load_result ("ACTIVE", ...). +const char * +target_plugin_load_result_str(enum target_plugin_load_result r); + +//! Short stable name for an @ref xrt_plugin_platform_state ("READY", ...). +const char * +target_plugin_platform_state_str(uint32_t state); + +/*! + * Live platform state of the active plug-in (`get_platform_state`, struct_size + * gated). Returns false (and fills UNKNOWN) when none is active or it does not + * report one. Thread-safe. + */ +bool +target_plugin_query_active_platform_state(struct xrt_plugin_platform_status *out); + +/*! + * Is @p iface a FALLBACK plug-in (ADR-045 D3)? True when it reports + * `XRT_PLUGIN_PLATFORM_FLAG_FALLBACK`; for a plug-in that does not report + * platform state, true only for the runtime's own simulation plug-in id + * (`sim-display`) — never decided by a vendor id. + */ +bool +target_plugin_iface_is_fallback(const struct xrt_plugin_iface *iface); + /*! * Returns the @ref xrt_plugin_instance handle returned by the active * plug-in's `iface->probe()`, or NULL if no plug-in is active or the @@ -204,6 +286,11 @@ target_plugin_get_active_instance(void); * registered (or no plug-in was active before), swap it in and return * its iface. Otherwise returns the unchanged current iface. * + * No live swap (ADR-045 D3): when the active plug-in is NOT a fallback + * (@ref target_plugin_iface_is_fallback), this returns it unchanged without + * re-probing anything — a vendor plug-in that loses its display reports + * NO_DISPLAY and stays selected. + * * Concretely: addresses issue #342 — the service starts mid-install * with only `sim-display` registered (ProbeOrder 200) and bakes its * factory pointers into `xrt_system_compositor_info`; the vendor diff --git a/src/xrt/targets/control_panel/control_panel_main.c b/src/xrt/targets/control_panel/control_panel_main.c index e6e30a7a44..db21a9874f 100644 --- a/src/xrt/targets/control_panel/control_panel_main.c +++ b/src/xrt/targets/control_panel/control_panel_main.c @@ -232,6 +232,13 @@ struct panel_state bool have_plugin; char pl_id[64], pl_name[128], pl_vendor[64], pl_ver[64]; char device[256]; + // ADR-045: every registered plug-in's platform state (from `info --json`). + int n_pstates; + struct + { + char line[400]; + bool degraded; // a non-READY state the user can act on + } pstates[8]; bool have_display; double w_m, h_m; int px, py; @@ -373,6 +380,30 @@ refresh_info(struct panel_state *s) cpy_str(s->device, sizeof(s->device), root, "device"); + // ADR-045: per-plug-in platform state + vendor hint, shown verbatim. + s->n_pstates = 0; + const cJSON *pls = cJSON_GetObjectItemCaseSensitive(root, "plugins"); + const cJSON *pe = NULL; + cJSON_ArrayForEach(pe, pls) + { + if (s->n_pstates >= 8) { + break; + } + char name[128], ver[64], st[32], hint[160], lr[32]; + cpy_str(name, sizeof(name), pe, "display_name"); + if (name[0] == '\0') { + cpy_str(name, sizeof(name), pe, "id"); + } + cpy_str(ver, sizeof(ver), pe, "version"); + cpy_str(st, sizeof(st), pe, "platform_state"); + cpy_str(hint, sizeof(hint), pe, "hint"); + cpy_str(lr, sizeof(lr), pe, "load_result"); + snprintf(s->pstates[s->n_pstates].line, sizeof(s->pstates[0].line), "%s %s - %s%s%s [%s]", name, ver, + st, hint[0] != '\0' ? ": " : "", hint, lr); + s->pstates[s->n_pstates].degraded = strcmp(st, "READY") != 0 && strcmp(st, "UNKNOWN") != 0; + s->n_pstates++; + } + const cJSON *d = cJSON_GetObjectItemCaseSensitive(root, "display"); if (cJSON_IsObject(d)) { s->have_display = true; @@ -845,6 +876,16 @@ draw_panel(struct panel_state *s) igTextColored(COL_GREEN, "ABI v%d (loader-verified match)", s->rt_abi); igText("Device : %s", s->device); } + if (s->n_pstates > 0) { + igText("Registered plug-ins (platform state):"); + for (int i = 0; i < s->n_pstates; i++) { + if (s->pstates[i].degraded) { + igTextColored(COL_AMBER, " %s", s->pstates[i].line); + } else { + igText(" %s", s->pstates[i].line); + } + } + } // ---- Display ---- if (s->have_display) { diff --git a/src/xrt/targets/service/main.c b/src/xrt/targets/service/main.c index 889d75fbc4..5b77bd9b70 100644 --- a/src/xrt/targets/service/main.c +++ b/src/xrt/targets/service/main.c @@ -198,6 +198,59 @@ register_for_restart(bool workspace_mode) } } +/* + * ADR-045 complete adoption. When a re-probe adopts a better display plug-in + * under a head device the fallback created, the IPC server ends its main loop + * once no client is connected (ipc_server_restart_requested) and this host + * starts a successor that waits for this process to exit, then builds its + * whole system on the new plug-in. A successor never restarts itself again + * for the same reason (no restart loop if a plug-in flaps between probes). + */ +#define SERVICE_ADOPTION_RESTART_ARG "--adoption-restart-after-pid" + +//! Start the successor instance (same exe, same integrity: this process's own +//! token, so a medium-integrity service stays medium). Returns true on success. +static bool +start_adoption_successor(bool workspace_mode) +{ + wchar_t exe[MAX_PATH]; + DWORD n = GetModuleFileNameW(NULL, exe, ARRAYSIZE(exe)); + if (n == 0 || n >= ARRAYSIZE(exe)) { + return false; + } + wchar_t cmd[MAX_PATH + 96]; + if (_snwprintf_s(cmd, ARRAYSIZE(cmd), _TRUNCATE, + L"\"%ls\" " + L"" SERVICE_ADOPTION_RESTART_ARG L" %lu%ls", + exe, (unsigned long)GetCurrentProcessId(), workspace_mode ? L" --workspace" : L"") < 0) { + return false; + } + STARTUPINFOW si = {0}; + si.cb = sizeof(si); + PROCESS_INFORMATION pi = {0}; + if (!CreateProcessW(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi)) { + return false; + } + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return true; +} + +//! Successor side: wait (bounded) for the predecessor to exit so the +//! singleton mutex and the IPC pipe are free. +static void +wait_for_predecessor(unsigned long pid) +{ + HANDLE h = OpenProcess(SYNCHRONIZE, FALSE, (DWORD)pid); + if (h == NULL) { + return; // already gone + } + DWORD w = WaitForSingleObject(h, 30000); + CloseHandle(h); + U_LOG_W("Adoption restart: predecessor pid %lu %s.", pid, + w == WAIT_OBJECT_0 ? "exited" : "did not exit within 30 s; continuing"); +} + // GUI subsystem entry point (no console window). int WINAPI WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdShow) @@ -232,6 +285,7 @@ WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdS bool workspace_mode = false; bool autostart = false; bool rm_restart = false; + unsigned long adoption_predecessor_pid = 0; for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--workspace") == 0) { workspace_mode = true; @@ -239,9 +293,18 @@ WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdS autostart = true; } else if (strcmp(argv[i], SERVICE_RM_RESTART_ARG) == 0) { rm_restart = true; + } else if (strcmp(argv[i], SERVICE_ADOPTION_RESTART_ARG) == 0 && i + 1 < argc) { + adoption_predecessor_pid = strtoul(argv[++i], NULL, 10); } } + // ADR-045: started by our own predecessor to complete a plug-in adoption. + if (adoption_predecessor_pid != 0) { + U_LOG_W("Started to complete a display plug-in adoption (predecessor pid %lu).", + adoption_predecessor_pid); + wait_for_predecessor(adoption_predecessor_pid); + } + // Restarted by Restart Manager after an installer closed us: record the // context it gave us, and never stay elevated (see relaunch_unelevated_via_shell). if (rm_restart) { @@ -323,6 +386,9 @@ WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdS .open = U_DEBUG_GUI_OPEN_AUTO, }, .workspace_mode = workspace_mode, + // ADR-045: restart once to complete an adoption — but never from an + // instance that is itself such a restart (bounded, no loop). + .allow_adoption_restart = adoption_predecessor_pid == 0, }; // MCP server moved out of the runtime in 2026-05 — workspace @@ -334,6 +400,14 @@ WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nCmdS u_crash_guard_run("service-main", service_main_body, &sma); int ret = sma.ret; + // ADR-045: the IPC server ended its loop to complete a plug-in adoption. + // The successor waits for this process to exit before it starts. + if (ipc_server_restart_requested()) { + bool started = start_adoption_successor(workspace_mode); + U_LOG_W("Adoption restart: %s.", started ? "successor started; this instance exits" + : "could NOT start a successor (start the service manually)"); + } + u_metrics_close(); // Shut down orchestrator (terminates managed children, unregisters hotkeys) diff --git a/src/xrt/targets/service/service_tray_win.c b/src/xrt/targets/service/service_tray_win.c index 28cdd4d99d..32fa03afb4 100644 --- a/src/xrt/targets/service/service_tray_win.c +++ b/src/xrt/targets/service/service_tray_win.c @@ -11,12 +11,17 @@ #include "service_workspace_registry.h" #include +#include #include +#include #include #include "util/u_crash_guard.h" #include "util/u_logging.h" +#include "server/ipc_server_interface.h" // ADR-045: ipc_server_request_display_reprobe +#include "target_plugin_loader.h" // ADR-045: per-plug-in platform state for the tooltip + #define IDI_DISPLAYXR_ICON_WHITE 101 #define IDI_DISPLAYXR_ICON_BLACK 102 #define WM_TRAYICON (WM_APP + 1) @@ -29,6 +34,10 @@ #define IDM_CONTROL_PANEL 1002 #define IDM_EXIT 1001 +// ADR-045: tooltip refresh of the display-processor status line. +#define TRAY_STATUS_TIMER_ID 1 +#define TRAY_STATUS_PERIOD_MS 5000 + // Workspace published-action IDs. Range matches // WORKSPACE_REGISTRY_MAX_ACTIONS (16) with a small margin for growth. #define IDM_WORKSPACE_ACTION_BASE 1040 @@ -49,6 +58,10 @@ static service_tray_shutdown_cb s_shutdown_cb = NULL; static service_tray_config_change_cb s_config_cb = NULL; static struct service_config s_config; +// ADR-045 R-c: registry waiter on the plug-in registration root. +static HANDLE s_reg_watch_thread = NULL; +static HANDLE s_reg_watch_stop = NULL; + /* * @@ -313,6 +326,179 @@ request_service_exit(void) } +/* + * + * Display-processor status (ADR-045 R-e) + * + */ + +/*! + * One line for the tray tooltip: which display processor is active, plus a + * degraded reason when it is the fallback (why the better-ranked plug-in is + * not active, in that plug-in's own words) or when the active plug-in reports + * NO_DISPLAY. Names come from the plug-in registration (DisplayName); nothing + * vendor-specific is known here. Never triggers discovery: it reads the + * loader's records, which the IPC server's instance filled in. + */ +static void +build_status_line(char *out, size_t cap) +{ + struct target_plugin_status st[16]; + int n = target_plugin_get_status(st, 16); + const struct target_plugin_status *active = NULL; + const struct target_plugin_status *best_other = NULL; + for (int i = 0; i < n; i++) { + if (st[i].result == TARGET_PLUGIN_RESULT_ACTIVE) { + active = &st[i]; + } else if (!st[i].fallback && st[i].result != TARGET_PLUGIN_RESULT_NOT_ATTEMPTED && + (best_other == NULL || st[i].probe_order < best_other->probe_order)) { + best_other = &st[i]; + } + } + + if (active == NULL) { + snprintf(out, cap, "%s", n == 0 ? "Display: starting" : "Display: no display processor"); + return; + } + const char *name = active->display_name[0] != '\0' ? active->display_name : active->id; + + if (active->fallback) { + if (best_other == NULL) { + snprintf(out, cap, "Display: %s (no 3D display plug-in)", name); + return; + } + const char *other = best_other->display_name[0] != '\0' ? best_other->display_name : best_other->id; + if (best_other->platform_state != XRT_PLUGIN_PLATFORM_STATE_UNKNOWN) { + snprintf(out, cap, "Display: %s. %s: %s%s%s", name, other, + target_plugin_platform_state_str(best_other->platform_state), + best_other->hint[0] != '\0' ? " - " : "", best_other->hint); + } else { + snprintf(out, cap, "Display: %s. %s: %s", name, other, + target_plugin_load_result_str(best_other->result)); + } + return; + } + + if (active->platform_state == XRT_PLUGIN_PLATFORM_STATE_NO_DISPLAY) { + snprintf(out, cap, "Display: %s: NO_DISPLAY%s%s", name, active->hint[0] != '\0' ? " - " : "", + active->hint); + return; + } + snprintf(out, cap, "Display: %s", name); +} + +static void +update_status_tooltip(void) +{ + char line[192]; + build_status_line(line, sizeof(line)); + + wchar_t wline[192]; + if (MultiByteToWideChar(CP_UTF8, 0, line, -1, wline, ARRAYSIZE(wline)) == 0) { + wline[0] = L'\0'; + } + wchar_t tip[ARRAYSIZE(s_nid.szTip)]; + _snwprintf_s(tip, ARRAYSIZE(tip), _TRUNCATE, L"DisplayXR Service\n%ls", wline); + if (wcscmp(tip, s_nid.szTip) == 0) { + return; + } + wcscpy_s(s_nid.szTip, ARRAYSIZE(s_nid.szTip), tip); + s_nid.uFlags = NIF_TIP; + Shell_NotifyIconW(NIM_MODIFY, &s_nid); + s_nid.uFlags = NIF_ICON | NIF_TIP | NIF_MESSAGE; +} + + +/* + * + * World-event sources for the display re-probe (ADR-045 R-c) + * + */ + +/*! + * Watch HKLM\Software\DisplayXR\DisplayProcessors (subtree) so a plug-in + * registered, removed or re-registered while the service runs is re-probed at + * once instead of on the next client connect. The root may not exist yet (no + * plug-in ever installed): wait on HKLM\Software\DisplayXR instead until it + * does. Requests go through ipc_server_request_display_reprobe, which only + * flags the IPC server's debounced worker, so nothing here blocks on a probe. + */ +static DWORD WINAPI +reg_watch_thread_body(LPVOID param) +{ + (void)param; + HANDLE ev = CreateEventW(NULL, FALSE, FALSE, NULL); + if (ev == NULL) { + return 1; + } + for (;;) { + bool watching_root = true; + HKEY key = NULL; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"Software\\DisplayXR\\DisplayProcessors", 0, + KEY_NOTIFY | KEY_WOW64_64KEY, &key) != ERROR_SUCCESS) { + watching_root = false; + if (RegOpenKeyExW(HKEY_LOCAL_MACHINE, L"Software\\DisplayXR", 0, KEY_NOTIFY | KEY_WOW64_64KEY, + &key) != ERROR_SUCCESS) { + key = NULL; + } + } + + DWORD w; + if (key != NULL && RegNotifyChangeKeyValue(key, TRUE, + REG_NOTIFY_CHANGE_NAME | REG_NOTIFY_CHANGE_LAST_SET | + REG_NOTIFY_THREAD_AGNOSTIC, + ev, TRUE) == ERROR_SUCCESS) { + HANDLE hs[2] = {s_reg_watch_stop, ev}; + w = WaitForMultipleObjects(2, hs, FALSE, INFINITE); + } else { + // Nothing to watch yet (or the API failed): poll slowly. + w = WaitForSingleObject(s_reg_watch_stop, 10000); + } + if (key != NULL) { + RegCloseKey(key); + } + if (w == WAIT_OBJECT_0) { + break; // stop + } + if (w == WAIT_OBJECT_0 + 1 && watching_root) { + ipc_server_request_display_reprobe("plug-in registration changed"); + } + } + CloseHandle(ev); + return 0; +} + +static void +reg_watch_start(void) +{ + s_reg_watch_stop = CreateEventW(NULL, TRUE, FALSE, NULL); + if (s_reg_watch_stop == NULL) { + return; + } + s_reg_watch_thread = CreateThread(NULL, 0, reg_watch_thread_body, NULL, 0, NULL); + if (s_reg_watch_thread == NULL) { + U_LOG_W("Could not start the plug-in registration watcher (error %lu).", GetLastError()); + } +} + +static void +reg_watch_stop(void) +{ + if (s_reg_watch_stop != NULL) { + SetEvent(s_reg_watch_stop); + } + if (s_reg_watch_thread != NULL) { + WaitForSingleObject(s_reg_watch_thread, 2000); + CloseHandle(s_reg_watch_thread); + s_reg_watch_thread = NULL; + } + if (s_reg_watch_stop != NULL) { + CloseHandle(s_reg_watch_stop); + s_reg_watch_stop = NULL; + } +} + + /* * * Session-end window (Restart Manager, logoff, shutdown) @@ -410,6 +596,18 @@ session_wnd_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) session_end_request_exit("WM_CLOSE", lParam); return 0; + // ADR-045 R-c: world events re-evaluate display-processor selection. Both + // are broadcast only to TOP-LEVEL windows, which is why they land here and + // not on the message-only tray window. The request only flags the IPC + // server's debounced worker; the refresh never runs on this thread. + case WM_DISPLAYCHANGE: ipc_server_request_display_reprobe("display change"); return 0; + + case WM_DEVICECHANGE: + if (wParam == DBT_DEVNODES_CHANGED) { + ipc_server_request_display_reprobe("device change"); + } + return TRUE; + default: return DefWindowProcW(hwnd, msg, wParam, lParam); } } @@ -490,6 +688,12 @@ tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; } + case WM_TIMER: + if (wParam == TRAY_STATUS_TIMER_ID) { + update_status_tooltip(); + } + return 0; + case WM_SETTINGCHANGE: // Windows theme changed — swap tray icon to match s_nid.hIcon = load_theme_icon(); @@ -497,6 +701,7 @@ tray_wnd_proc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) return 0; case WM_DESTROY: + KillTimer(hwnd, TRAY_STATUS_TIMER_ID); Shell_NotifyIconW(NIM_DELETE, &s_nid); PostQuitMessage(0); return 0; @@ -566,6 +771,10 @@ tray_thread_body(LPVOID param) Shell_NotifyIconW(NIM_ADD, &s_nid); + // ADR-045 R-e: keep the tooltip's display-processor line current (cheap: + // reads the loader's records + one platform-state query). + SetTimer(s_tray_hwnd, TRAY_STATUS_TIMER_ID, TRAY_STATUS_PERIOD_MS, NULL); + // Signal that we're ready SetEvent(s_ready_event); @@ -632,12 +841,17 @@ service_tray_init(service_tray_shutdown_cb shutdown_cb, CloseHandle(s_ready_event); s_ready_event = NULL; + // ADR-045 R-c: plug-in registration changes re-probe selection. + reg_watch_start(); + return s_tray_hwnd != NULL; } void service_tray_cleanup(void) { + reg_watch_stop(); + if (s_tray_hwnd) { // Tell the tray thread to exit PostMessageW(s_tray_hwnd, WM_DESTROY, 0, 0);