microsoft / microsoft/PowerToys
[FancyZones] Wrong layout applied after dock/sleep: stale applied-layouts entry wins (constant-0 hash + monitor-number fallback in DeviceId equality)
- Dominant language
- C
- Stars
- 139k
- Forks
- 8.6k
- PR merge metrics
- PR metrics pending
Description
### Microsoft PowerToys version
0.100.0
### Installation method
WinGet
### Area(s) with issue?
FancyZones
### Steps to reproduce
**Setup:** 3 monitors (laptop panel + 2 external ultrawides) through a docking station,
4 named Windows virtual desktops, each with its own FancyZones layout per monitor. Topology
changes between two locations and across sleep/wake and overnight screen lock.
**Trigger:** after a sleep/wake, undock/redock, or overnight lock, FancyZones applies the
**wrong** saved layout to a monitor on a given virtual desktop (one desktop's layout appears
to "bleed" onto another), without any manual change.
#### Root cause (traced in the 0.100.0 source — the point of this report)
`applied-layouts.json` accumulates **multiple entries for the same physical monitor on the
same virtual desktop**, differing only by `monitor-instance` and `monitor-number`, and
FancyZones then matches the live monitor to the **wrong** entry. Three code defects in
`FancyZonesLib` combine to cause this:
**1. The layout map's hash is a constant `0`.**
`AppliedLayouts::TAppliedLayoutsMap` is `std::unordered_map`
(`AppliedLayouts.h`), but the hash specialization in `FancyZonesDataTypes.h` is:
```cpp
template<> struct hash {
size_t operator()(const FancyZonesDataTypes::WorkAreaId&) const { return 0; }
};
```
Every entry lands in one bucket, so `GetDeviceLayout()`'s `m_layouts.find(id)` degrades to a
**linear scan that returns the first entry for which `operator==` is true** — the result is
decided by file/insertion order.
**2. `operator==(DeviceId)` falls back to the monitor *number* when the instance differs**
(`FancyZonesDataTypes.h`):
```cpp
inline bool operator==(const DeviceId& lhs, const DeviceId& rhs) {
if (lhs.id != rhs.id) return false;
if (lhs.instanceId != rhs.instanceId) return lhs.number == rhs.number; // <-- problem
return true;
}
```
`monitor-number` is reassigned by Windows on topology changes and is **not stable or unique**.
So a live monitor can compare *equal* to a **stale entry left over from a different dock/port**
merely because their `number` coincides. Combined with defect #1 (first match in file order
wins), FancyZones applies that stale entry's layout.
**3. Stale per-(monitor, desktop) entries are never pruned.** Because `monitor-instance`
encodes the dock/port (it changes when the same monitor is plugged into a different port/dock,
and is *shared* by different monitors on the same port), every reconnection can add a new entry
while old ones linger as wrong-match candidates.
There is also a latent correctness bug: `operator<(DeviceId)` and `operator<(WorkAreaId)` in the
same file are not valid strict weak orderings (e.g. `operator<(WorkAreaId)` compares
`lhs.virtualDesktopId.Data1 < rhs...Data1 || Data2 < Data2 || Data3 < Data3` of two possibly
different GUIDs). Harmless while the container is `unordered_map`, but a trap for future ordered
use or `std::sort`.
A likely-related second symptom: `FancyZonesCLI get-monitors` reported the monitors as belonging
to a *stale* virtual desktop GUID while the registry (`CurrentVirtualDesktop`) and
`get-active-layout` agreed on the real one. `AppliedLayouts::SyncVirtualDesktops()` copies a
layout to **all** desktops when the current VD transitions from `GUID_NULL`; if VD detection
returns `GUID_NULL` spuriously after sleep/dock, this can overwrite other desktops' layouts.
#### Concrete evidence (from my `applied-layouts.json`)
The physical monitor `DELA243` (serial redacted) had **two** entries on the same virtual desktop
`{5032E820-…}`:
```jsonc
// stale leftover from a previous dock connection
{ "device": { "monitor": "DELA243", "monitor-instance": "4&…&UID20550", "monitor-number": 1,
"serial-number": "…", "virtual-desktop": "{5032E820-…}" },
"applied-layout": { "type": "custom", "uuid": "{609F1F3E-…}" } }
// current connection — the one that should win
{ "device": { "monitor": "DELA243", "monitor-instance": "4&…&UID8261", "monitor-number": 3,
"serial-number": "…", "virtual-desktop": "{5032E820-…}" },
"applied-layout": { "type": "grid", "uuid": "{821FE1CE-…}" } }
```
The live `DELA243` was instance `…UID8261` — yet `get-active-layout` reported the **custom**
`{609F1F3E}` layout from the stale `…UID20550` entry. Across the whole file the same monitor
appears under three instances (`UID8261`, `UID12613`, `UID20550`) with conflicting layouts.
#### Reliable manual workaround
Remove every entry for a monitor on a desktop and keep a single one keyed to the current device
record, then restart PowerToys so FancyZones re-reads the file. Collapsing to one entry per
(monitor, desktop) makes the correct layout stick.
#### How this differs from existing issues
Closely related reports are all **closed** and are user *symptom* reports without the code-level
cause: #40638 (same "by port vs display id" intuition; closed, never triaged), #16370 (the
reset-to-*default* variant; Priority-1, closed as duplicate), #13733 (partial fix committed, yet
recurrence), #28371 and #36897 (closed as duplicate), and the now-closed tracker #12985. None
pins the specific defects above (constant-`0` hash → first-match-by-file-order; the `number`
fallback in `operator==(DeviceId)`; un-pruned stale entries) with file evidence and a repro.
#### Proposed fix (direction)
1. Match monitors by a stable identity — prefer `id + serialNumber` (already in the data) over
the volatile `monitor-instance`/`number`, guarding empty/non-unique serials (some panels
report `"0"`).
2. Drop the `number` fallback in `operator==(DeviceId)` so a differing `instanceId` can't be made
"equal" by a coincidental monitor number; resolve identity via serial instead.
3. Prune stale entries for the same `(monitor id + serial, virtualDesktop)` on load/save.
4. Give `WorkAreaId` a real hash (the current one returns `0`) and fix
`operator<(DeviceId)`/`operator<(WorkAreaId)` to be valid strict weak orderings.
5. Re-check `SyncVirtualDesktops()`' copy-to-all-desktops path against a spurious `GUID_NULL`
current desktop after sleep/dock.
Relevant files: `src/modules/fancyzones/FancyZonesLib/FancyZonesData/AppliedLayouts.cpp`,
`src/modules/fancyzones/FancyZonesLib/FancyZonesDataTypes.h`.
### ✔️ Expected Behavior
Each physical monitor keeps its assigned layout per virtual desktop across sleep/wake,
undock/redock, and lock. FancyZones should match a monitor to its layout by a **stable**
identity, and stale entries from previous topologies should not be able to win the match.
### ❌ Actual Behavior
After a topology change, FancyZones applies a layout from a stale `applied-layouts.json` entry
belonging to a previous dock/port for that monitor, because (a) the map hash is constant so the
first `operator==` match in file order wins, and (b) `operator==(DeviceId)` treats a differing
instance as equal when the (unstable) monitor `number` coincides.
### Other Software
Connected through a docking station; the external monitors' `monitor-instance` changes by
dock/port.
Contributor guide
Research direction
Start with src/modules/fancyzones/FancyZonesLib/FancyZonesData/AppliedLayouts.cpp and src/modules/fancyzones/FancyZonesLib/FancyZonesDataTypes.h, then trace GetDeviceLayout(), DeviceId equality, hashing, and SyncVirtualDesktops(). Reproduce the topology-change case if possible and inspect applied-layouts.json. Done means stable monitor matching and no stale entry can override the current layout across sleep, docking, and virtual desktops.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp
- Domain
- desktop
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 35/100