diff --git a/Cargo.lock b/Cargo.lock index d42992b9c..08f955661 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3564,6 +3564,8 @@ dependencies = [ "serde_json", "uds_windows", "ureq", + "wayland-client", + "wayland-protocols", "winresource", "winsafe", ] diff --git a/apps/pdfcraft/Cargo.toml b/apps/pdfcraft/Cargo.toml index 1850877a3..b7028552b 100644 --- a/apps/pdfcraft/Cargo.toml +++ b/apps/pdfcraft/Cargo.toml @@ -28,6 +28,12 @@ log = { workspace = true } # behind a safe API. Same version as PhotoCraft. Pinned exactly: review the diff before bumping. fmv-macos-events = "=0.1.0" +[target.'cfg(target_os = "linux")'.dependencies] +# Asks the Wayland compositor which GPU it draws with (linux-dmabuf feedback), so the window is +# drawn on that one (src/compositor_gpu.rs). Both are already in the tree through winit. +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client"] } + [dev-dependencies] # Waits for wgpu's adapter, device and error-scope futures in tests (already in the tree through wgpu). pollster = "1.0" diff --git a/apps/pdfcraft/src/compositor_gpu.rs b/apps/pdfcraft/src/compositor_gpu.rs new file mode 100644 index 000000000..8edf23db9 --- /dev/null +++ b/apps/pdfcraft/src/compositor_gpu.rs @@ -0,0 +1,177 @@ +//! The GPU the Wayland compositor draws with. +//! +//! A window drawn on another GPU reaches the compositor as that GPU's buffers, and the compositor +//! may not show them: it can refuse them and end the window's connection (#96), or take them and +//! show nothing, with no error on either side. The second happened on a hybrid laptop whose +//! Hyprland runs on the NVIDIA GPU (`AQ_DRM_DEVICES`) while the built-in panel hangs off the Intel +//! one: the panel rule picked Intel and the window stayed empty. Which GPU drives a display is a +//! guess at the compositor's GPU; the compositor states it, as the `main_device` of its +//! linux-dmabuf feedback (protocol version 4): the device it imports client buffers on. + +#[cfg(target_os = "linux")] +use wayland_client::globals::{GlobalListContents, registry_queue_init}; +#[cfg(target_os = "linux")] +use wayland_client::protocol::wl_registry::{self, WlRegistry}; +#[cfg(target_os = "linux")] +use wayland_client::{Connection, Dispatch, QueueHandle}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_feedback_v1::{self, ZwpLinuxDmabufFeedbackV1}; +#[cfg(target_os = "linux")] +use wayland_protocols::wp::linux_dmabuf::zv1::client::zwp_linux_dmabuf_v1::{self, ZwpLinuxDmabufV1}; + +/// PCI `(vendor, device)` ids of the GPU the Wayland compositor draws with. `None` outside a +/// Wayland session, when the compositor doesn't say (linux-dmabuf older than version 4), when its +/// device isn't a PCI GPU, and on other systems. +#[cfg(target_os = "linux")] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + let device = match main_device() { + Ok(device) => device, + Err(why) => { + log::debug!("the compositor's GPU is unknown: {why}"); + return None; + } + }; + let pci = pci_ids(std::path::Path::new("/sys/dev/char"), device); + match pci { + Some((vendor, id)) => log::info!("the compositor draws with {vendor:04x}:{id:04x}"), + None => log::debug!("the compositor's device {:?} has no PCI ids", major_minor(device)), + } + pci +} + +#[cfg(not(target_os = "linux"))] +pub(crate) fn compositor_gpu() -> Option<(u32, u32)> { + None +} + +/// The `main_device` of the compositor's default linux-dmabuf feedback, asked on a connection of +/// its own that is closed again before the window opens. +#[cfg(target_os = "linux")] +fn main_device() -> Result { + let conn = Connection::connect_to_env().map_err(|e| format!("no Wayland compositor: {e}"))?; + let (globals, mut queue) = registry_queue_init::(&conn).map_err(|e| e.to_string())?; + let qh = queue.handle(); + // Version 4 brought the feedback; older compositors only list formats. + let dmabuf: ZwpLinuxDmabufV1 = globals.bind(&qh, 4..=4, ()).map_err(|e| format!("linux-dmabuf version 4: {e}"))?; + let _feedback = dmabuf.get_default_feedback(&qh, ()); + let mut state = Feedback::default(); + // The compositor sends the whole feedback, ending with `done`, in reply; the bound only keeps + // a compositor that never ends it from holding up the start. + for _ in 0..4 { + if state.done { + break; + } + queue.roundtrip(&mut state).map_err(|e| e.to_string())?; + } + // Dropping the connection releases the objects. + let device = state.main_device.ok_or("the compositor named no main device")?; + // A `dev_t`, in this machine's byte order. + let device: [u8; 8] = device.try_into().map_err(|d: Vec| format!("a {}-byte device number", d.len()))?; + Ok(u64::from_ne_bytes(device)) +} + +/// What [`main_device`] collects from the feedback events. +#[cfg(target_os = "linux")] +#[derive(Default)] +struct Feedback { + main_device: Option>, + done: bool, +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event(_: &mut Self, _: &WlRegistry, _: wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + // Version 4 sends no format or modifier events: the feedback replaces them. + fn event(_: &mut Self, _: &ZwpLinuxDmabufV1, _: zwp_linux_dmabuf_v1::Event, _: &(), _: &Connection, _: &QueueHandle) {} +} + +#[cfg(target_os = "linux")] +impl Dispatch for Feedback { + fn event( + state: &mut Self, + _: &ZwpLinuxDmabufFeedbackV1, + event: zwp_linux_dmabuf_feedback_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + match event { + zwp_linux_dmabuf_feedback_v1::Event::MainDevice { device } => state.main_device = Some(device), + zwp_linux_dmabuf_feedback_v1::Event::Done => state.done = true, + _ => {} + } + } +} + +/// The major and minor numbers of a Linux `dev_t`, as glibc's and musl's `major()` and `minor()` +/// split it. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn major_minor(device: u64) -> (u64, u64) { + let major = ((device & 0x0000_0000_000f_ff00) >> 8) | ((device & 0xffff_f000_0000_0000) >> 32); + let minor = (device & 0x0000_0000_0000_00ff) | ((device & 0x0000_0fff_fff0_0000) >> 12); + (major, minor) +} + +/// The PCI ids of the GPU behind DRM device `device` (its `cardN` or `renderDN` node), from +/// `/MAJOR:MINOR/device/{vendor,device}`. +#[cfg_attr(not(target_os = "linux"), allow(dead_code))] +fn pci_ids(sys_dev_char: &std::path::Path, device: u64) -> Option<(u32, u32)> { + let read_hex = |p: std::path::PathBuf| -> Option { + let s = std::fs::read_to_string(p).ok()?; + u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok() + }; + let (major, minor) = major_minor(device); + let gpu = sys_dev_char.join(format!("{major}:{minor}")).join("device"); + Some((read_hex(gpu.join("vendor"))?, read_hex(gpu.join("device"))?)) +} + +#[cfg(test)] +mod tests { + /// glibc's `makedev()`. + fn makedev(major: u64, minor: u64) -> u64 { + ((major & 0x0000_0fff) << 8) | ((major & 0xffff_f000) << 32) | (minor & 0x0000_00ff) | ((minor & 0xffff_ff00) << 12) + } + + #[test] + fn device_numbers_split_into_major_and_minor() { + // DRM nodes: card1 is 226:1, renderD128 is 226:128. + assert_eq!(super::major_minor(0xe201), (226, 1)); + assert_eq!(super::major_minor(0xe280), (226, 128)); + for (major, minor) in [(0, 0), (226, 129), (511, 1_048_575), (0xfff, 0xff), (0x1000, 0x100), (0xffff_ffff, 0xffff_ffff)] { + assert_eq!(super::major_minor(makedev(major, minor)), (major, minor)); + } + } + + #[cfg(target_os = "linux")] + #[test] + fn the_gpu_behind_a_device_number_comes_from_sysfs() -> std::io::Result<()> { + let dir = std::env::temp_dir().join(format!("pdfcraft-dev-char-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + // /sys/dev/char/226:128 links to the render node, whose `device` is the PCI GPU. + std::fs::create_dir_all(dir.join("226:128/device"))?; + std::fs::write(dir.join("226:128/device/vendor"), "0x10de\n")?; + std::fs::write(dir.join("226:128/device/device"), "0x25ac\n")?; + // A device that isn't a PCI GPU (a virtual one) has no ids. + std::fs::create_dir_all(dir.join("226:0/device"))?; + let render = super::pci_ids(&dir, makedev(226, 128)); + let virtual_gpu = super::pci_ids(&dir, makedev(226, 0)); + let missing = super::pci_ids(&dir, makedev(226, 1)); + std::fs::remove_dir_all(&dir)?; + assert_eq!(render, Some((0x10de, 0x25ac))); + assert_eq!(virtual_gpu, None); + assert_eq!(missing, None); + Ok(()) + } + + #[test] + fn the_compositor_names_a_pci_gpu_or_nothing() { + // Whatever this session has: no compositor (CI), one that doesn't say, or a PCI GPU. + if let Some((vendor, _)) = super::compositor_gpu() { + assert_ne!(vendor, 0); + } + } +} diff --git a/apps/pdfcraft/src/main.rs b/apps/pdfcraft/src/main.rs index cbcf5d706..a1cfd4b38 100644 --- a/apps/pdfcraft/src/main.rs +++ b/apps/pdfcraft/src/main.rs @@ -31,6 +31,7 @@ use pdfcraft_ui_egui::PdfCraftApp; #[cfg(target_os = "macos")] mod apple_events; +mod compositor_gpu; mod logging; mod single_instance; mod text_scale; @@ -458,6 +459,10 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()> /// desktop with a monitor on each GPU, issue #445) the discrete one wins, as the integrated one /// may fail to present there. Where the system doesn't say which GPU drives a display (macOS, /// containers, remote sessions) the power preference alone decides. +/// - Under Wayland, the GPU the compositor draws with comes before all of that: the compositor +/// says which one it is ([`compositor_gpu::compositor_gpu`]), and frames from another GPU may +/// never reach the screen, even from the GPU behind the built-in panel when the compositor runs +/// on the discrete one. /// - On Windows, use Direct3D 12, falling back to OpenGL, and never load Vulkan drivers unless /// `WGPU_BACKEND` asks for them. Creating a Vulkan instance loads every installed Vulkan driver /// into the process, and a faulty one (an Intel driver in issue #37) crashed PdfCraft before @@ -477,7 +482,8 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { if std::env::var_os("WGPU_POWER_PREF").is_none() { setup.power_preference = eframe::wgpu::PowerPreference::LowPower; let displays = display_gpus(); - if !displays.is_empty() { + let compositor = compositor_gpu::compositor_gpu(); + if !displays.is_empty() || compositor.is_some() { setup.native_adapter_selector = Some(std::sync::Arc::new(move |adapters, surface| { let usable: Vec<&eframe::wgpu::Adapter> = adapters.iter().filter(|a| surface.is_none_or(|s| a.is_surface_supported(s))).collect(); let infos: Vec<(u32, u32, eframe::wgpu::DeviceType)> = usable @@ -487,7 +493,7 @@ fn configure_gpu(native: &mut eframe::NativeOptions) { (info.vendor, info.device, info.device_type) }) .collect(); - let picked = pick_adapter(&infos, &displays).and_then(|i| usable.get(i)).map(|a| (*a).clone()); + let picked = pick_adapter(&infos, &displays, compositor).and_then(|i| usable.get(i)).map(|a| (*a).clone()); match &picked { // Which GPU draws is the first question when a window stays black (#8, #378, #445). Some(a) => { @@ -620,12 +626,12 @@ fn linux_display_gpus(drm: &std::path::Path) -> Vec { gpus } -/// Index of the adapter to draw with: one that drives a display (by PCI ids) first. Among several -/// of those, the one driving the primary display (a hybrid laptop's panel, #8; the primary display -/// on Windows, #378), or else, with no primary display known (a Linux desktop with a monitor on -/// each GPU, #445), the discrete one. Otherwise the most frugal kind: integrated, discrete, other, -/// virtual, software. -fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu]) -> Option { +/// Index of the adapter to draw with: the GPU the Wayland compositor draws with (`compositor`, by +/// PCI ids) first, then one that drives a display. Among several of those, the one driving the +/// primary display (a hybrid laptop's panel, #8; the primary display on Windows, #378), or else, +/// with no primary display known (a Linux desktop with a monitor on each GPU, #445), the discrete +/// one. Otherwise the most frugal kind: integrated, discrete, other, virtual, software. +fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[DisplayGpu], compositor: Option<(u32, u32)>) -> Option { use eframe::wgpu::DeviceType; let display = |pci: (u32, u32)| displays.iter().find(|g| g.pci == pci); let multi_gpu_desktop = displays.len() > 1 && !displays.iter().any(|g| g.primary); @@ -646,7 +652,7 @@ fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[D DeviceType::VirtualGpu => 4, DeviceType::Cpu => 5, }; - (!drives_display, !drives_primary, rank) + (compositor != Some((*vendor, *device)), !drives_display, !drives_primary, rank) }) .map(|(i, _)| i) } @@ -967,17 +973,17 @@ mod tests { #[test] fn pick_adapter_prefers_the_gpu_driving_the_monitors() { // A desktop whose monitors are all on the discrete GPU: the integrated one shows black. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)]), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA)], None), Some(0)); // Issue #378: the same on Windows, where the Ryzen integrated GPU without a monitor crashed // the display driver. Windows also says which display is the primary one. - assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)]), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(NVIDIA)], None), Some(0)); } #[test] fn pick_adapter_prefers_the_gpu_driving_the_primary_display() { // A Windows desktop with a monitor on each GPU: the window opens on the primary display. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)]), Some(0)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), panel(NVIDIA)], None), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); } #[test] @@ -1009,10 +1015,10 @@ mod tests { #[test] fn pick_adapter_keeps_the_integrated_gpu_on_hybrid_laptops() { // Issue #8: the panel is on the integrated GPU, an external monitor on the discrete one. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(NVIDIA), panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU)], None), Some(1)); } #[test] @@ -1021,17 +1027,32 @@ mod tests { use eframe::wgpu::DeviceType; let desktop = [(INTEL_IGPU.0, INTEL_IGPU.1, DeviceType::IntegratedGpu), (NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (0, 0, DeviceType::Cpu)]; - assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)]), Some(1)); - assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(INTEL_IGPU), monitor(NVIDIA)], None), Some(1)); + assert_eq!(super::pick_adapter(&desktop, &[monitor(NVIDIA), monitor(INTEL_IGPU)], None), Some(1)); // A GPU driving no display still loses to one that does. - assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)]), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor(AMD_IGPU), monitor(INTEL_IGPU)], None), Some(1)); + } + + #[test] + fn pick_adapter_prefers_the_gpu_the_compositor_draws_with() { + // A hybrid laptop whose Wayland compositor runs on the discrete GPU (Hyprland with + // `AQ_DRM_DEVICES` naming it first): the integrated GPU drives the built-in panel, yet the + // compositor shows nothing it draws, and reports no error either. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some(NVIDIA)), Some(0)); + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(NVIDIA)), Some(0)); + // The usual hybrid laptop, with the compositor on the integrated GPU: as before (#8). + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU), monitor(NVIDIA)], Some(AMD_IGPU)), Some(1)); + // No GPU drives a display as far as sysfs says (a container), but the compositor knows. + assert_eq!(super::pick_adapter(&adapters(), &[], Some(NVIDIA)), Some(0)); + // A compositor GPU no adapter matches (a virtual one) leaves the displays in charge. + assert_eq!(super::pick_adapter(&adapters(), &[panel(AMD_IGPU)], Some((0x1af4, 0x1050))), Some(1)); } #[test] fn pick_adapter_falls_back_to_low_power_without_a_match() { - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))]), Some(1)); - assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))]), Some(1)); - assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)]), None); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234))], None), Some(1)); + assert_eq!(super::pick_adapter(&adapters(), &[monitor((0x8086, 0x1234)), monitor((0x8086, 0x5678))], None), Some(1)); + assert_eq!(super::pick_adapter(&[], &[monitor(NVIDIA)], None), None); } #[test] diff --git a/docs/development.md b/docs/development.md index 168197ab9..9708db4ca 100644 --- a/docs/development.md +++ b/docs/development.md @@ -67,7 +67,7 @@ OpenGL can't start either, PdfCraft exits with both errors in the log. The fallb | `RUST_LOG` | Log levels for standard error and the log file (see [Logs](#logs)) | | `RUST_BACKTRACE` | `1` adds a backtrace to the report of an internal error | | `XDG_DATA_HOME` | Linux/FreeBSD: base of the settings folder (`pdfcraft/`), the log folder and crash recovery | -| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | +| `WGPU_POWER_PREF` | GPU choice; by default PdfCraft draws on the GPU the Wayland compositor draws with (it says so through linux-dmabuf feedback), else the GPU that drives the primary display (Windows) or the built-in panel (Linux), else the low-power (integrated) GPU. The log says which one was chosen | | `WGPU_BACKEND` | Graphics backend; by default Windows uses Direct3D 12, falling back to OpenGL | | `PDFCRAFT_RENDERER` | `gl` starts with OpenGL (glow) and never loads wgpu; `wgpu` reports a wgpu failure instead of retrying with OpenGL; unset, wgpu is retried with OpenGL when it can't start (see [Renderer fallback](#renderer-fallback)) | | `CRAFT_FONTS_DIR` | Build time: a [craft-fonts](https://github.com/storytold/craft-fonts) checkout to embed (Japanese fonts) |