diff options
| author | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
|---|---|---|
| committer | srdusr <[email protected]> | 2025-02-15 14:56:00 +0200 |
| commit | 0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd (patch) | |
| tree | 7672d0af277664f457c6c9462925c0005fe35dcf /crates/wayland/src/udev/mod.rs | |
| parent | 413daa7ba2ea0ebd1424c024fd0566423aaea3f8 (diff) | |
| download | srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.tar.gz srdwm-0a4c4b5941fe982ccb3d3175e26d9d83f0025ffd.zip | |
Checkpoint: preserve all uncommitted rust-rewrite worktree work
Safety commit before reconciling this worktree with main, which has
diverged with its own separate fixes today. Nothing here is reviewed
or curated yet - this exists purely so none of this work can be lost
to a git operation, disk issue, or worktree cleanup while that
reconciliation happens.
Diffstat (limited to 'crates/wayland/src/udev/mod.rs')
| -rw-r--r-- | crates/wayland/src/udev/mod.rs | 294 |
1 files changed, 276 insertions, 18 deletions
diff --git a/crates/wayland/src/udev/mod.rs b/crates/wayland/src/udev/mod.rs index 501e3fc..1490d13 100644 --- a/crates/wayland/src/udev/mod.rs +++ b/crates/wayland/src/udev/mod.rs @@ -33,13 +33,14 @@ use std::rc::Rc; use std::time::{Duration, Instant}; use smithay::backend::input::{ - Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent, + AbsolutePositionEvent, Axis, ButtonState as BackendButtonState, Event as InputEventTrait, GestureBeginEvent as BackendGestureBeginEvent, GestureEndEvent as BackendGestureEndEvent, GesturePinchUpdateEvent as BackendGesturePinchUpdateEvent, InputEvent, PointerAxisEvent, PointerButtonEvent, PointerMotionEvent, }; use smithay::backend::libinput::{LibinputInputBackend, LibinputSessionInterface}; use smithay::backend::renderer::damage::OutputDamageTracker; use smithay::backend::renderer::element::memory::MemoryRenderBufferRenderElement; +use smithay::backend::renderer::element::solid::SolidColorBuffer; use smithay::backend::renderer::element::Kind; use smithay::backend::renderer::pixman::PixmanRenderer; use smithay::backend::renderer::{Bind, ImportDma}; @@ -128,6 +129,11 @@ pub(crate) struct UdevHead { /// A flip is in flight; the next frame for this head waits for the DRM /// page-flip event (matched by `crtc`) before starting. pub(crate) flip_pending: bool, + /// When the current `flip_pending` was set - lets `render_udev_frame` + /// notice a page-flip event that never arrived (or arrived but matched + /// no head - see `FLIP_TIMEOUT`'s own doc comment) instead of waiting + /// on it forever. Meaningless while `flip_pending` is `false`. + pub(crate) flip_pending_since: Instant, /// Per-buffer-slot age passed to `damage_tracker.render_output`: how /// many *damage-producing* renders ago that exact buffer was last /// brought fully up to date. 0 means "never rendered, contents @@ -149,6 +155,16 @@ pub(crate) struct UdevHead { /// Origin of this head in the global coordinate space. pub(crate) location: Point<i32, Logical>, pub(crate) size: (i32, i32), + /// The DRM mode this head was actually brought up with - kept so a VT- + /// switch resume can reassert the CRTC with its real connector and + /// mode (see `register_session_notifier`'s own `ActivateSession` arm), + /// rather than the empty connector list and `None` mode that call used + /// to pass, which does not reassert a CRTC at all - it is DRM/KMS's + /// own shape for *disabling* one. Confirmed live: switching back to + /// srdwm's VT after switching away left the screen black, with no + /// further VT switch (either direction) able to recover it, matching a + /// CRTC left disabled rather than restored. + pub(crate) mode: DrmMode, } /// Everything the DRM/udev backend needs that the nested winit backend @@ -173,14 +189,112 @@ pub(crate) struct UdevState { /// backend needed the session handle after startup, so it was never /// retained anywhere before this. pub(crate) session: LibSeatSession, + /// Connector names administratively disabled via `srd dispatch set + /// output enabled <name> false` - still physically connected (DRM + /// still reports/probes them), just deliberately not driven. Checked + /// by `reprobe_outputs`'s own "added" loop so an unrelated hotplug + /// event doesn't resurrect one of these the next time anything else + /// plugs or unplugs - without this, the very next `Changed` uevent + /// (any connector, not just this one) would see a disabled-but-still- + /// present connector as newly "added" (present in a fresh probe, + /// absent from `heads`, exactly the condition that branch already + /// uses to detect a real hotplug) and bring it straight back up. + pub(crate) disabled_connectors: std::collections::HashSet<String>, + /// `WorkspaceId` this backend last built `custom_elements` for -- + /// compared against `WindowManager::current_workspace()` at the top of + /// every `render_udev_frame` call so a switch can force every head's + /// `ages` back to `[0, 0]` (see that call site's own comment for why). + /// `None` before the very first frame, which already renders fully + /// regardless (every head starts with `ages: [0, 0]` - see + /// `UdevHead`'s own field). + pub(crate) last_rendered_workspace: Option<srdwm_core::WorkspaceId>, } impl UdevState { - /// Bounding box of every head, used to clamp pointer motion. - fn bounds(&self) -> (f64, f64) { - let w = self.heads.iter().map(|h| h.location.x + h.size.0).max().unwrap_or(0); - let h = self.heads.iter().map(|h| h.location.y + h.size.1).max().unwrap_or(0); - (w as f64, h as f64) + /// Bounding box of every head, used to clamp pointer motion -- + /// `(min_x, min_y, max_x, max_y)`, not just a `(width, height)` + /// implicitly anchored at `(0, 0)` (what this used to return, and what + /// every call site clamped into with a hardcoded `0.0` floor). That + /// was only ever correct while every head's `location.x`/`location.y` + /// stayed `>= 0`, true for `reprobe_outputs`' own left-to-right hotplug + /// layout but not guaranteed once `set_output_position` exists: an + /// "extend left"/"extend above" arrangement (a real one, requested and + /// applied live by an AGS peer session's monitor-layout panel) places + /// the newly-added head at a *negative* `x`/`y` relative to whichever + /// one stayed at the origin. With the old `(0, w)` clamp, the pointer + /// could never actually cross into that negative-origin region at + /// all - reported live as "clicked it now I can't go to other + /// monitor at all" once such an arrangement was applied. The AGS + /// side has since started normalising every arrangement it sends so + /// the leftmost/topmost edge lands at `0` again, which works around + /// this from outside, but srdwm's own pointer clamp assuming an origin + /// no other part of this backend actually enforces is the real bug -- + /// fixed here instead of just left for every future caller to avoid. + fn bounds(&self) -> (f64, f64, f64, f64) { + bounds_of(self.heads.iter().map(|h| (h.location.x, h.location.y, h.size.0, h.size.1))) + } +} + +/// The actual arithmetic behind [`UdevState::bounds`], over plain +/// `(x, y, width, height)` tuples rather than real `UdevHead`s - pulled +/// out so it's testable without a real DRM/`Card` handle, which every +/// `UdevHead` in this module otherwise needs to even construct. +fn bounds_of(heads: impl Iterator<Item = (i32, i32, i32, i32)>) -> (f64, f64, f64, f64) { + let mut min_x = 0; + let mut min_y = 0; + let mut max_x = 0; + let mut max_y = 0; + let mut any = false; + for (x, y, w, h) in heads { + if !any { + min_x = x; + min_y = y; + any = true; + } else { + min_x = min_x.min(x); + min_y = min_y.min(y); + } + max_x = max_x.max(x + w); + max_y = max_y.max(y + h); + } + (min_x as f64, min_y as f64, max_x as f64, max_y as f64) +} + +#[cfg(test)] +mod bounds_tests { + use super::bounds_of; + + #[test] + fn single_head_at_origin_matches_the_old_zero_anchored_behaviour() { + assert_eq!(bounds_of([(0, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 1920.0, 1080.0)); + } + + #[test] + fn two_heads_left_to_right_from_origin() { + assert_eq!(bounds_of([(0, 0, 1920, 1080), (1920, 0, 1920, 1080)].into_iter()), (0.0, 0.0, 3840.0, 1080.0)); + } + + #[test] + fn negative_origin_head_is_reflected_in_min_not_clamped_to_zero() { + // The actual regression this exists for: an "extend left" + // arrangement places the new head at a negative x, and the old + // `(width, height)`-only version of this function (implicitly + // anchored at 0) made that head's own region completely + // unreachable by pointer motion - reported live as "clicked it + // now I can't go to other monitor at all". + let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (-1920, 0, 1920, 1080)].into_iter()); + assert_eq!((min_x, min_y, max_x, max_y), (-1920.0, 0.0, 1920.0, 1080.0)); + } + + #[test] + fn negative_origin_above_is_reflected_in_min_y() { + let (min_x, min_y, max_x, max_y) = bounds_of([(0, 0, 1920, 1080), (0, -1080, 1920, 1080)].into_iter()); + assert_eq!((min_x, min_y, max_x, max_y), (0.0, -1080.0, 1920.0, 1080.0)); + } + + #[test] + fn no_heads_at_all_is_a_degenerate_zero_sized_box_not_a_panic() { + assert_eq!(bounds_of(std::iter::empty()), (0.0, 0.0, 0.0, 0.0)); } } @@ -204,8 +318,33 @@ impl UdevHead { /// buffer (software rendering writes into its own owned image, not the /// scanout memory directly, to avoid tying that image's lifetime to an /// mmap - see this module's docs) and flips to it. - fn copy_and_flip(&mut self, card: &Card, back: usize) -> std::io::Result<()> { - let (src_stride, height) = (self.buffers[back].image.stride(), self.buffers[back].image.height()); + /// `damage` is the exact set of rects `render_output` just re-rendered + /// into `self.buffers[back].image` - an empty slice means "copy + /// everything" (the locked/lock-UI render paths don't bother computing + /// per-rect damage, so this is also the safe fallback for any caller + /// that can't cheaply produce real rects), otherwise only those rows' + /// column ranges are copied. + /// + /// Used to be an unconditional full-buffer copy regardless of how + /// little of the frame actually changed - `render_output`'s own + /// age-based damage tracking already leaves everything outside + /// `damage` untouched in `image` (correct: that buffer's untouched + /// pixels still match what was on screen `ages[back]` frames ago), so + /// `dumb` - this same buffer's DRM-mapped twin, previously brought up + /// to date by this exact function on that same past frame - is + /// already correct everywhere outside `damage` too. Copying the whole + /// buffer anyway meant a full `stride * height` memcpy on every single + /// presented frame, for content as small as a moved cursor or a + /// blinking terminal caret - confirmed as the largest per-frame CPU + /// cost on this software `PixmanRenderer` backend by a direct + /// comparison against niri's DRM-composited present path (which has no + /// equivalent copy step at all) and mutter's native backend (which + /// explicitly restricts its own swap to damaged regions, + /// `swap_buffers_with_damage`) - this is the same technique, adapted + /// to a raw byte copy instead of a GL/EGL damage extension. + fn copy_and_flip(&mut self, card: &Card, back: usize, damage: &[Rectangle<i32, Physical>]) -> std::io::Result<()> { + let (src_stride, height, width) = + (self.buffers[back].image.stride(), self.buffers[back].image.height(), self.buffers[back].image.width()); let byte_len = src_stride * height; // SAFETY: `image` owns this memory and outlives the byte slice we // construct from it here; we only read, and only for the duration @@ -224,23 +363,142 @@ impl UdevHead { let dst_stride = self.buffers[back].dumb.pitch() as usize; { let mut mapping = card.map_dumb_buffer(&mut self.buffers[back].dumb)?; - let dst = mapping.as_mut(); - let row_len = src_stride.min(dst_stride); - for row in 0..height { - let s = row * src_stride; - let d = row * dst_stride; - if s + row_len > src.len() || d + row_len > dst.len() { - break; - } - dst[d..d + row_len].copy_from_slice(&src[s..s + row_len]); - } + copy_damaged_rows(src, mapping.as_mut(), src_stride, dst_stride, width, height, damage); } card.page_flip(self.crtc, self.buffers[back].fb, PageFlipFlags::EVENT, None)?; self.flip_pending = true; + self.flip_pending_since = Instant::now(); Ok(()) } } +/// The row/column copy math behind [`DrmHead::copy_and_flip`], pulled out +/// as a free function over plain slices so it's testable without a real +/// `Card`/dumb buffer - everything else in that method needs live DRM +/// state, this doesn't. `damage` empty means "copy every row in full" +/// (`width`/`height` are pixels, `src_stride`/`dst_stride` bytes); a +/// non-empty `damage` copies only each rect's row/column span, clamped to +/// the narrower of the two strides and to `width`/`height` the same way +/// the full-copy path always has. +fn copy_damaged_rows(src: &[u8], dst: &mut [u8], src_stride: usize, dst_stride: usize, width: usize, height: usize, damage: &[Rectangle<i32, Physical>]) { + let full_row_len = src_stride.min(dst_stride); + let copy_row = |dst: &mut [u8], row: usize, col_start_bytes: usize, col_len: usize| { + let s = row * src_stride + col_start_bytes; + let d = row * dst_stride + col_start_bytes; + let len = col_len.min(full_row_len.saturating_sub(col_start_bytes)); + if len == 0 || s + len > src.len() || d + len > dst.len() { + return; + } + dst[d..d + len].copy_from_slice(&src[s..s + len]); + }; + if damage.is_empty() { + for row in 0..height { + copy_row(dst, row, 0, full_row_len); + } + return; + } + const BPP: usize = 4; // Argb8888/Xrgb8888, same assumption every other raw-buffer path in this codebase makes. + for rect in damage { + let y0 = rect.loc.y.max(0) as usize; + let y1 = (rect.loc.y.saturating_add(rect.size.h).max(0) as usize).min(height); + let x0 = rect.loc.x.max(0) as usize; + let x1 = (rect.loc.x.saturating_add(rect.size.w).max(0) as usize).min(width); + if x1 <= x0 { + continue; + } + let (col_start_bytes, col_len) = (x0 * BPP, (x1 - x0) * BPP); + for row in y0..y1 { + copy_row(dst, row, col_start_bytes, col_len); + } + } +} + +#[cfg(test)] +mod copy_damaged_rows_tests { + use super::copy_damaged_rows; + use smithay::utils::{Physical, Point, Rectangle, Size}; + + fn rect(x: i32, y: i32, w: i32, h: i32) -> Rectangle<i32, Physical> { + Rectangle::new(Point::from((x, y)), Size::from((w, h))) + } + + /// A tiny 4x3 BGRA canvas, one distinct byte value per pixel's blue + /// channel (row * width + col) so a wrong offset or a skipped pixel + /// shows up as the wrong number, not just "still zero". + fn make_src(width: usize, height: usize) -> Vec<u8> { + let mut buf = vec![0u8; width * height * 4]; + for (i, px) in buf.chunks_exact_mut(4).enumerate() { + px[0] = i as u8; + px[3] = 255; + } + buf + } + + #[test] + fn empty_damage_copies_every_row_in_full() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[]); + assert_eq!(dst, src); + } + + #[test] + fn a_damage_rect_updates_only_its_own_pixels() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + // Only the single pixel at (1, 1). + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(1, 1, 1, 1)]); + let idx = (1 * w + 1) * 4; + assert_eq!(dst[idx], src[idx], "the damaged pixel must be copied"); + assert_eq!(dst[0], 0, "a pixel outside the damage rect must stay untouched"); + assert_eq!(dst[dst.len() - 4], 0, "the last row's pixel is also outside the rect and must stay untouched"); + } + + #[test] + fn a_full_width_row_rect_copies_that_row_only() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(0, 1, w as i32, 1)]); + let row1 = w * 4..w * 4 * 2; + assert_eq!(dst[row1.clone()], src[row1], "row 1 must be fully copied"); + assert_eq!(&dst[..w * 4], &vec![0u8; w * 4][..], "row 0 must stay untouched"); + assert_eq!(&dst[w * 4 * 2..], &vec![0u8; w * 4][..], "row 2 must stay untouched"); + } + + #[test] + fn a_rect_extending_past_the_buffer_is_clamped_not_panicking() { + let (w, h) = (4, 3); + let src = make_src(w, h); + let mut dst = vec![0u8; w * h * 4]; + // Starts inside the buffer but both extends past its right/bottom + // edge and would run off a naive unclamped copy. + copy_damaged_rows(&src, &mut dst, w * 4, w * 4, w, h, &[rect(2, 2, 100, 100)]); + let idx = (2 * w + 2) * 4; + assert_eq!(dst[idx], src[idx], "the in-bounds corner of an oversized rect must still be copied"); + } + + #[test] + fn a_wider_destination_stride_does_not_shear_rows() { + // Destination row padded 4 extra bytes past the source's own + // stride - the same "driver-padded dumb buffer pitch" case the + // full-copy path was already written to handle; damage-restricted + // copying must preserve that, not just the empty-damage fallback. + let (w, h) = (4, 3); + let src = make_src(w, h); + let dst_stride = w * 4 + 4; + let mut dst = vec![0u8; dst_stride * h]; + copy_damaged_rows(&src, &mut dst, w * 4, dst_stride, w, h, &[rect(0, 0, w as i32, h as i32)]); + for row in 0..h { + let s = row * w * 4..row * w * 4 + w * 4; + let d = row * dst_stride..row * dst_stride + w * 4; + assert_eq!(dst[d], src[s], "row {row} must land at the destination's own stride, not the source's"); + } + } +} + mod capture; mod drm; mod outputs; |