Displays: mirror-aware layout, backend keep-or-revert guard, duplicate-mode crash fix #67

Merged
Breadway merged 5 commits from fix/displays-safety-mirroring into main 2026-10-14 11:58:09 +08:00
Owner

Closes BSET-48
Closes BSET-49
Closes BSET-62

What

BSET-62 (crash on DVI outputs). Hyprland reports some modes twice (DVI-I-1 and DVI-I-2: 30 modes, 25 unique). The resolution picker was keyed by the mode string, so Svelte threw each_key_duplicate and the whole Displays section failed to load. The backend now drops repeated modes and repeated output names, keeping order. The frontend also runs compositor lists through uniqueBy before any keyed each, so a backend that sends duplicates cannot crash the page. All keyed each blocks on the Displays pages were audited: the others use static lists (SCALES, TURNS, MODES, tabs) or output names, which are now de-duplicated.

BSET-49 (mirrors misrepresented).

  • mirrorOf from hyprctl monitors all -j is the numeric id of the source output ("0" means eDP-1), not a name. The old code used it as a name, so a mirror could never match. It is now resolved to a name, and names, numbers, "none", "" and "-1" are all handled.
  • Live state is read with monitors all, so turned-off outputs are known too.
  • Mirrored outputs are drawn as one dashed screen with a "Mirrored" badge and joined names (for example "eDP-1 + DVI-I-1").
  • Controls reflect the real relationship. A mirror shows "Mirroring" with the output it follows and can be set back to a separate display. A source lists what mirrors it with a "Stop mirroring" button.
  • Changes to unrelated outputs keep existing mirrors (tested). Stopping a mirror places the output beside the others, not on top of them.
  • The page polls live state while visible, refreshes after every apply, and reads the real state again after a revert.

BSET-48 (safeguards). Every change to resolution, scale, rotation, adaptive sync, position (drag) and mirroring goes through a new DisplayGuard in src/src/commands/display_guard.rs, which extends the existing Keep/Revert dialog instead of adding a second one.

  1. Captures the whole live layout, mirrors included, before changing anything.
  2. Validates the target. It refuses a layout with no enabled display, a self-mirror, a mirror of a missing, turned-off or already-mirroring output, an output that is no longer connected, and an unusable scale, rotation or size. Nothing reaches the compositor in those cases.
  3. Applies it through hyprctl eval (the reply must be "ok") and reads the compositor back to check it matches. On an apply failure or a mismatch it restores the snapshot, checks that too, and reports the error.
  4. Holds the snapshot with the deadline in a backend thread. Timeout, an unplugged display that the pending layout needs, explicit Revert and app exit all restore the snapshot and verify it. Closing or leaving the page cannot cancel the revert.
  5. Only Keep writes to disk (monitors.json and the login include). If saving fails after Keep, the user is told the layout is live but not saved.
  6. The dialog countdown only shows the backend deadline. It starts from the seconds left, a page opened again during a pending change picks it up, and a display-guard-outcome event clears it when the backend decides.

Commands: apply_display_layout, keep_display_layout, revert_display_layout and display_guard_status replace apply_monitor_layout and persist_monitor_layout (only Display.svelte used them).

Checked

  • frontend: npm ci, npm run check (0 errors, 0 warnings), npm test (all pass, including new uniqueBy and displayLayout tests), npm run build.
  • src: cargo fmt --all -- --check, cargo clippy --all-targets --locked -- -D warnings, cargo test --locked (167 passed).
  • The real capture of the user's layout is the test fixture (src/src/commands/testdata/hyprctl-monitors-all.json). Tests cover: mirror parsing (DVI-I-1 mirrors eDP-1, DVI-I-2 separate with transform 1), duplicate mode removal (25 unique), snapshot and restore planning (mirror sources first, replayed on restore), the no-enabled-display guard, bad mirror layouts, apply failure, a compositor that ignores the change (caught by read-back), keep then nothing reverts, explicit revert, unattended timeout revert from the backend with no frontend involved, revert after timeout, unplug while pending, second change refused while one is pending, exit revert, save failure after Keep, and stopping then restarting a mirror.
  • The mock compositor runs the real generated Lua through mlua with a stub hl.monitor, so syntax errors or missing keys fail the tests. The guard tests were repeated 60 times with no failures (one early flaky test was fixed: it polled pending instead of the recorded outcome).

Not verified live

No hyprctl command was run against a real session and the app was not launched, by design. So these are untested on a real Hyprland 0.56:

  • that hl.monitor({ output = ..., disabled = true }) is accepted (only used if a layout ever carries a disabled output; the page itself never turns a display off);
  • that hl.monitor with mirror = "<name>" re-applies an existing mirror without visible flicker, and that a mirror source listed first is really required;
  • the exact refresh read-back (the check tolerates 0.5 Hz);
  • the on-screen look of the grouped mirror, the badge and the dialog;
  • the app-exit hook (RunEvent::Exit).

Limit: the deadline lives in the app process. If the app is killed hard (SIGKILL, crash) while a change is pending, nothing can revert it. Closing the window, leaving the page and a normal exit do revert.

Capability changes

Nothing was removed outright. Behaviour that changed or narrowed:

  • Dragging a panel is no longer applied and saved immediately. It now uses the Keep/Revert countdown and is saved only after Keep (this is the point of BSET-48).
  • Controls are disabled while a change is applying or waiting for an answer (resolution, scale, rotation, mirror, drag). Before, only the mirror select was.
  • Only one change can be pending at a time. A second one is refused until the first is kept or reverted.
  • A mirrored output no longer has its own box on the canvas, so it cannot be dragged on its own. It is drawn and moved with its source, because a mirror has no position of its own.
  • A source that has mirrors no longer shows the "Mirror another display" select, it shows "Mirrored by" with Stop mirroring buttons instead. This prevents mirror chains, which the guard refuses. A mirror can only follow an output that is not itself mirroring.
  • Persisted layouts leave out turned-off outputs. Rules for them already in monitors.json are untouched, as before.
  • Refresh rates now keep two decimals (59.94 stays 59.94) where they used to be rounded to a whole number.
  • The page subtitle text changed ("Drag to arrange. Every change can be kept or reverted.").
  • Added: a Display selector row, so any output including a mirror can be selected, and a live poll while visible.
Closes BSET-48 Closes BSET-49 Closes BSET-62 ## What **BSET-62 (crash on DVI outputs).** Hyprland reports some modes twice (DVI-I-1 and DVI-I-2: 30 modes, 25 unique). The resolution picker was keyed by the mode string, so Svelte threw each_key_duplicate and the whole Displays section failed to load. The backend now drops repeated modes and repeated output names, keeping order. The frontend also runs compositor lists through `uniqueBy` before any keyed each, so a backend that sends duplicates cannot crash the page. All keyed each blocks on the Displays pages were audited: the others use static lists (`SCALES`, `TURNS`, `MODES`, `tabs`) or output names, which are now de-duplicated. **BSET-49 (mirrors misrepresented).** - `mirrorOf` from `hyprctl monitors all -j` is the numeric id of the source output ("0" means eDP-1), not a name. The old code used it as a name, so a mirror could never match. It is now resolved to a name, and names, numbers, "none", "" and "-1" are all handled. - Live state is read with `monitors all`, so turned-off outputs are known too. - Mirrored outputs are drawn as one dashed screen with a "Mirrored" badge and joined names (for example "eDP-1 + DVI-I-1"). - Controls reflect the real relationship. A mirror shows "Mirroring" with the output it follows and can be set back to a separate display. A source lists what mirrors it with a "Stop mirroring" button. - Changes to unrelated outputs keep existing mirrors (tested). Stopping a mirror places the output beside the others, not on top of them. - The page polls live state while visible, refreshes after every apply, and reads the real state again after a revert. **BSET-48 (safeguards).** Every change to resolution, scale, rotation, adaptive sync, position (drag) and mirroring goes through a new `DisplayGuard` in `src/src/commands/display_guard.rs`, which extends the existing Keep/Revert dialog instead of adding a second one. 1. Captures the whole live layout, mirrors included, before changing anything. 2. Validates the target. It refuses a layout with no enabled display, a self-mirror, a mirror of a missing, turned-off or already-mirroring output, an output that is no longer connected, and an unusable scale, rotation or size. Nothing reaches the compositor in those cases. 3. Applies it through `hyprctl eval` (the reply must be "ok") and reads the compositor back to check it matches. On an apply failure or a mismatch it restores the snapshot, checks that too, and reports the error. 4. Holds the snapshot with the deadline in a backend thread. Timeout, an unplugged display that the pending layout needs, explicit Revert and app exit all restore the snapshot and verify it. Closing or leaving the page cannot cancel the revert. 5. Only Keep writes to disk (monitors.json and the login include). If saving fails after Keep, the user is told the layout is live but not saved. 6. The dialog countdown only shows the backend deadline. It starts from the seconds left, a page opened again during a pending change picks it up, and a `display-guard-outcome` event clears it when the backend decides. Commands: `apply_display_layout`, `keep_display_layout`, `revert_display_layout` and `display_guard_status` replace `apply_monitor_layout` and `persist_monitor_layout` (only Display.svelte used them). ## Checked - frontend: `npm ci`, `npm run check` (0 errors, 0 warnings), `npm test` (all pass, including new `uniqueBy` and `displayLayout` tests), `npm run build`. - src: `cargo fmt --all -- --check`, `cargo clippy --all-targets --locked -- -D warnings`, `cargo test --locked` (167 passed). - The real capture of the user's layout is the test fixture (`src/src/commands/testdata/hyprctl-monitors-all.json`). Tests cover: mirror parsing (DVI-I-1 mirrors eDP-1, DVI-I-2 separate with transform 1), duplicate mode removal (25 unique), snapshot and restore planning (mirror sources first, replayed on restore), the no-enabled-display guard, bad mirror layouts, apply failure, a compositor that ignores the change (caught by read-back), keep then nothing reverts, explicit revert, unattended timeout revert from the backend with no frontend involved, revert after timeout, unplug while pending, second change refused while one is pending, exit revert, save failure after Keep, and stopping then restarting a mirror. - The mock compositor runs the real generated Lua through mlua with a stub `hl.monitor`, so syntax errors or missing keys fail the tests. The guard tests were repeated 60 times with no failures (one early flaky test was fixed: it polled pending instead of the recorded outcome). ## Not verified live No hyprctl command was run against a real session and the app was not launched, by design. So these are untested on a real Hyprland 0.56: - that `hl.monitor({ output = ..., disabled = true })` is accepted (only used if a layout ever carries a disabled output; the page itself never turns a display off); - that `hl.monitor` with `mirror = "<name>"` re-applies an existing mirror without visible flicker, and that a mirror source listed first is really required; - the exact refresh read-back (the check tolerates 0.5 Hz); - the on-screen look of the grouped mirror, the badge and the dialog; - the app-exit hook (`RunEvent::Exit`). Limit: the deadline lives in the app process. If the app is killed hard (SIGKILL, crash) while a change is pending, nothing can revert it. Closing the window, leaving the page and a normal exit do revert. ## Capability changes Nothing was removed outright. Behaviour that changed or narrowed: - **Dragging a panel is no longer applied and saved immediately.** It now uses the Keep/Revert countdown and is saved only after Keep (this is the point of BSET-48). - **Controls are disabled while a change is applying or waiting for an answer** (resolution, scale, rotation, mirror, drag). Before, only the mirror select was. - **Only one change can be pending at a time.** A second one is refused until the first is kept or reverted. - **A mirrored output no longer has its own box on the canvas**, so it cannot be dragged on its own. It is drawn and moved with its source, because a mirror has no position of its own. - **A source that has mirrors no longer shows the "Mirror another display" select**, it shows "Mirrored by" with Stop mirroring buttons instead. This prevents mirror chains, which the guard refuses. A mirror can only follow an output that is not itself mirroring. - **Persisted layouts leave out turned-off outputs.** Rules for them already in monitors.json are untouched, as before. - **Refresh rates now keep two decimals** (59.94 stays 59.94) where they used to be rounded to a whole number. - The page subtitle text changed ("Drag to arrange. Every change can be kept or reverted."). - Added: a Display selector row, so any output including a mirror can be selected, and a live poll while visible.
Hyprland lists some modes twice (the DVI outputs report 30 modes, 25
unique). The resolution picker keyed its each block by the mode string,
so Svelte threw each_key_duplicate and the whole Displays section failed
to load when a DVI output was selected.

The backend now drops repeated modes and repeated output names while
keeping order, and the frontend runs compositor lists through uniqueBy
before any keyed each, so a backend that sends duplicates cannot crash
the page. Adds the real hyprctl monitors all capture as a test fixture.
Display changes now go through a DisplayGuard owned by the Rust backend.
It captures the whole live layout (mirrors included) from
hyprctl monitors all -j, applies the new layout through hyprctl eval,
reads the compositor back to confirm it matches, and holds the snapshot
while a backend deadline runs. Timeout, an unplugged display, explicit
Revert and app exit all restore the snapshot and verify the result; only
Keep writes to disk. A layout with no enabled display, a self-mirror, a
mirror of a missing, disabled or mirroring output, or an unusable scale
is refused before anything reaches the compositor.

mirrorOf is the numeric id of the source output on current Hyprland, so
it is now resolved to a name; DVI-I-1 mirroring eDP-1 (id 0) is read
correctly instead of being treated as a separate screen. Refresh rates
keep two decimals so a 59.94 Hz mode is not applied as 60 Hz.

The compositor sits behind a Compositor trait, and the tests drive the
real generated Lua through a stub hl.monitor, using the user's captured
layout as the fixture. apply_monitor_layout and persist_monitor_layout
are replaced by apply_display_layout, keep_display_layout,
revert_display_layout and display_guard_status.
Mirrored outputs are drawn as a single dashed screen with a Mirrored
badge and joined names. The controls show the real relationship: a
mirror shows what it mirrors and offers to use it as a separate display,
and a source lists what mirrors it with a Stop mirroring button. Changes
to unrelated outputs leave mirrors alone.

Resolution, scale, rotation, adaptive sync, position (drag) and
mirroring all go through apply_display_layout and show the existing
Keep/Revert countdown. The countdown only displays the backend deadline:
it starts from the seconds left, a page opened while a change is pending
picks it up again, and the backend's outcome event clears it. The page
polls live state while visible so external compositor changes show up,
and refreshes after every apply.
RunEvent::Exit does not run when the process is signalled, so logging out,
pkill or a systemd stop during the keep-or-revert countdown left the change
applied. Found by a live VM check: kill -TERM mid-countdown kept transform=1.
A tokio signal task now reverts through the guard and then exits normally.
SIGKILL still cannot be handled (BSET-67).
Merge remote-tracking branch 'origin/main' into fix/displays-safety-mirroring
All checks were successful
check / check (push) Successful in 7m25s
7a55f598d6
Sign in to join this conversation.
No reviewers
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
Breadway/bos-settings!67
No description provided.