fix(multi-monitor): canonicalize display IDs on GDI enumeration to prevent DXGI/GDI drift #11

Open
opened 2026-07-06 10:29:45 -04:00 by hex · 0 comments
Owner

Symptoms

On a multi-monitor host, the remote-desktop video for a given display_id can come from one monitor while SendInput-based clicks/moves addressed to that same display_id land on a different monitor (or land on the right monitor but at the wrong coordinates within the virtual desktop). There is no crash and no error logged — the video stream looks correct, the picker UI looks correct, only pointer placement is wrong, which is the hardest class of bug to notice from logs alone and looks identical to the other, already-fixed multi-monitor input bugs in this repo's history.

This has not been observed in production — it's a latent gap found while auditing the existing (fixed) multi-monitor SendInput issues, not a reported incident.

Root cause

display_id is a plain u32 that means "position in an enumeration list," but the agent maintains two independently-ordered enumerations on Windows with no cross-referencing between them:

  • DXGI: DxgiCapture::enumerate_outputs (crates/zeno-agent/src/platform/windows.rs:138-202) walks IDXGIFactory1::EnumAdapters1 (outer loop) then IDXGIAdapter1::EnumOutputs (inner loop), assigning id = out.len() as u32 in that walk order. This is the list sent to the SPA (DxgiCapture::displays(), windows.rs:469-471) via RemoteDesktopDisplays whenever DXGI is the active backend (the default/common case), and it's what RemoteDesktopSelectDisplay addresses.
  • GDI: enumerate_gdi_displays (windows.rs:49-92) walks EnumDisplayMonitors, assigning id = displays.len() as u32 in that callback order. get_display_rect(display_id) (windows.rs:569-575) — which the SendInput coordinate-remap path uses to turn a wire display_id back into a virtual-desktop rect — looks the id up in this list, not DXGI's.

Microsoft's documented EnumAdapters1 contract only guarantees that the adapter driving the primary output is returned at adapter index 0 (IDXGIFactory1::EnumAdapters1); it says nothing about the order of a given adapter's other outputs, nothing about where a second adapter's outputs land relative to GDI's monitor order, and nothing about output order surviving driver reinitialization or a docked/hybrid-graphics reconfiguration. EnumDisplayMonitors (Multiple Monitor Applications on Different Systems) is a completely separate walk with its own ordering rules. Nothing in the codebase maps one id space onto the other — a display_id is produced by whichever enumeration built the list the SPA saw, then consumed by whichever enumeration the code path on the other end happens to call.

The primary monitor itself is not at risk (both enumerations independently identify the true primary — DXGI via the x == 0 && y == 0 virtual-desktop-origin heuristic at windows.rs:180-182, GDI via the authoritative MONITORINFOF_PRIMARY flag at windows.rs:67 — and Windows defines the primary monitor as the one at the virtual-desktop origin, so these two heuristics can't disagree). The risk is entirely in secondary monitor ids, which have no equivalent cross-check.

Why we don't see it today

The user's current hardware (Alonzo-pc) is single-adapter. With exactly one adapter, enumerate_outputs's outer loop runs once, so its output ordering reduces to a single EnumOutputs walk — and in practice a lone GPU's own output enumeration and Windows' GDI monitor enumeration tend to agree because both ultimately reflect the same one driver's notion of "which output is which." The two-independent-orderings hazard only becomes observable with more than one adapter contributing outputs (dGPU + iGPU, an added eGPU, a USB/DisplayLink adapter, or a docked laptop) or after a hybrid-graphics/driver reconfiguration reshuffles which physical adapter object backs a given monitor's duplication path.

Proposed fix (shape only)

Make GDI's EnumDisplayMonitors order the single canonical id space, and have the DXGI path resolve into it instead of minting its own. Concretely: when DXGI enumerates an output, look up that output's virtual-desktop rect (already available from IDXGIOutput::GetDesc) against the GDI list and reuse that monitor's GDI-assigned id, rather than assigning id = out.len() from the DXGI walk. select_display and get_display_rect then agree by construction because both are keyed off the same GDI enumeration. This is the smallest change (touches enumerate_outputs's id assignment only, no wire-protocol or helper-process architecture changes) and matches the pattern the codebase already uses elsewhere (get_primary_display_rect deliberately re-derives from GDI rather than trusting a DXGI-native answer, for the same reason). The alternative of matching by rect/MonitorFromRect at SendInput time instead of by id was considered but rejected for now — it would require threading the captured frame's rect into the input-remap call sites (run.rs/remote_helper.rs), which is a larger change for the same result.

Reproduction recipe (hypothetical — needs a multi-adapter host)

  1. A Windows host with two monitors driven by two different adapters (e.g. a laptop with hybrid graphics, or a desktop with a dGPU + motherboard iGPU output both connected to a display).
  2. Start a remote session; open both monitor windows via the picker.
  3. Compare the id values in the RemoteDesktopDisplays message (DXGI order) against enumerate_gdi_displays()'s order for the same physical monitors (loggable by temporarily adding a debug line, or inferred from which monitor's video appears under which id in the picker vs. which monitor a click lands on).
  4. If the secondary monitor's DXGI id and GDI id differ, clicking in the secondary monitor's remote window will move the pointer against the other monitor's rect — visibly landing on the wrong screen or at an offset position.

References

## Symptoms On a multi-monitor host, the remote-desktop video for a given `display_id` can come from one monitor while `SendInput`-based clicks/moves addressed to that same `display_id` land on a *different* monitor (or land on the right monitor but at the wrong coordinates within the virtual desktop). There is no crash and no error logged — the video stream looks correct, the picker UI looks correct, only pointer placement is wrong, which is the hardest class of bug to notice from logs alone and looks identical to the other, already-fixed multi-monitor input bugs in this repo's history. This has **not been observed in production** — it's a latent gap found while auditing the existing (fixed) multi-monitor `SendInput` issues, not a reported incident. ## Root cause `display_id` is a plain `u32` that means "position in an enumeration list," but the agent maintains **two independently-ordered enumerations** on Windows with no cross-referencing between them: - **DXGI**: `DxgiCapture::enumerate_outputs` (`crates/zeno-agent/src/platform/windows.rs:138-202`) walks `IDXGIFactory1::EnumAdapters1` (outer loop) then `IDXGIAdapter1::EnumOutputs` (inner loop), assigning `id = out.len() as u32` in that walk order. This is the list sent to the SPA (`DxgiCapture::displays()`, `windows.rs:469-471`) via `RemoteDesktopDisplays` whenever DXGI is the active backend (the default/common case), and it's what `RemoteDesktopSelectDisplay` addresses. - **GDI**: `enumerate_gdi_displays` (`windows.rs:49-92`) walks `EnumDisplayMonitors`, assigning `id = displays.len() as u32` in *that* callback order. `get_display_rect(display_id)` (`windows.rs:569-575`) — which the `SendInput` coordinate-remap path uses to turn a wire `display_id` back into a virtual-desktop rect — looks the id up in **this** list, not DXGI's. Microsoft's documented `EnumAdapters1` contract only guarantees that *the adapter driving the primary output* is returned at adapter index 0 ([IDXGIFactory1::EnumAdapters1](https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgifactory1-enumadapters1)); it says nothing about the order of a given adapter's *other* outputs, nothing about where a second adapter's outputs land relative to GDI's monitor order, and nothing about output order surviving driver reinitialization or a docked/hybrid-graphics reconfiguration. `EnumDisplayMonitors` ([Multiple Monitor Applications on Different Systems](https://learn.microsoft.com/en-us/windows/win32/gdi/multiple-monitor-applications-on-different-systems)) is a completely separate walk with its own ordering rules. Nothing in the codebase maps one id space onto the other — a `display_id` is produced by whichever enumeration built the list the SPA saw, then consumed by whichever enumeration the code path on the other end happens to call. The primary monitor itself is *not* at risk (both enumerations independently identify the true primary — DXGI via the `x == 0 && y == 0` virtual-desktop-origin heuristic at `windows.rs:180-182`, GDI via the authoritative `MONITORINFOF_PRIMARY` flag at `windows.rs:67` — and Windows defines the primary monitor as the one at the virtual-desktop origin, so these two heuristics can't disagree). The risk is entirely in **secondary monitor ids**, which have no equivalent cross-check. ## Why we don't see it today The user's current hardware (Alonzo-pc) is single-adapter. With exactly one adapter, `enumerate_outputs`'s outer loop runs once, so its output ordering reduces to a single `EnumOutputs` walk — and in practice a lone GPU's own output enumeration and Windows' GDI monitor enumeration tend to agree because both ultimately reflect the same one driver's notion of "which output is which." The two-independent-orderings hazard only becomes observable with more than one adapter contributing outputs (dGPU + iGPU, an added eGPU, a USB/DisplayLink adapter, or a docked laptop) or after a hybrid-graphics/driver reconfiguration reshuffles which physical adapter object backs a given monitor's duplication path. ## Proposed fix (shape only) Make GDI's `EnumDisplayMonitors` order the single canonical id space, and have the DXGI path resolve *into* it instead of minting its own. Concretely: when DXGI enumerates an output, look up that output's virtual-desktop rect (already available from `IDXGIOutput::GetDesc`) against the GDI list and reuse *that* monitor's GDI-assigned id, rather than assigning `id = out.len()` from the DXGI walk. `select_display` and `get_display_rect` then agree by construction because both are keyed off the same GDI enumeration. This is the smallest change (touches `enumerate_outputs`'s id assignment only, no wire-protocol or helper-process architecture changes) and matches the pattern the codebase already uses elsewhere (`get_primary_display_rect` deliberately re-derives from GDI rather than trusting a DXGI-native answer, for the same reason). The alternative of matching by rect/`MonitorFromRect` at `SendInput` time instead of by id was considered but rejected for now — it would require threading the captured frame's rect into the input-remap call sites (`run.rs`/`remote_helper.rs`), which is a larger change for the same result. ## Reproduction recipe (hypothetical — needs a multi-adapter host) 1. A Windows host with two monitors driven by two different adapters (e.g. a laptop with hybrid graphics, or a desktop with a dGPU + motherboard iGPU output both connected to a display). 2. Start a remote session; open both monitor windows via the picker. 3. Compare the `id` values in the `RemoteDesktopDisplays` message (DXGI order) against `enumerate_gdi_displays()`'s order for the same physical monitors (loggable by temporarily adding a debug line, or inferred from which monitor's video appears under which id in the picker vs. which monitor a click lands on). 4. If the secondary monitor's DXGI id and GDI id differ, clicking in the secondary monitor's remote window will move the pointer against the *other* monitor's rect — visibly landing on the wrong screen or at an offset position. ## References - [IDXGIFactory1::EnumAdapters1 (dxgi.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgifactory1-enumadapters1) — documents the "primary-output adapter is index 0" guarantee and its limits. - [Multiple Monitor Applications on Different Systems - Win32 apps | Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/gdi/multiple-monitor-applications-on-different-systems) — GDI's independent monitor-enumeration model. - [MOUSEINPUT (winuser.h) - Win32 apps | Microsoft Learn](https://learn.microsoft.com/en-us/windows/win32/api/winuser/ns-winuser-mouseinput) — `MOUSEEVENTF_ABSOLUTE`/`MOUSEEVENTF_VIRTUALDESK` semantics the `SendInput` remap path relies on. - `crates/zeno-agent/src/platform/windows.rs:138-202` — `DxgiCapture::enumerate_outputs` (DXGI id assignment). - `crates/zeno-agent/src/platform/windows.rs:49-92` — `enumerate_gdi_displays` (GDI id assignment). - `crates/zeno-agent/src/platform/windows.rs:566-575` — `get_display_rect` (the `SendInput` remap path's GDI-order lookup). - `crates/zeno-agent/src/platform/windows.rs:469-483` — `DxgiCapture::displays()`/`select_display` (the DXGI-order list sent to the SPA).
hex self-assigned this 2026-07-06 10:29:45 -04:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
hex/zeno-rmm#11
No description provided.