Issue #96: on Linux, draw on the GPU that drives the monitors (#105)

* Issue #96: on Linux, draw on the GPU that drives the monitors

Preferring the low-power GPU (issue #8) picked the integrated GPU on
desktops whose monitors are all on the discrete one, so the window was
black (Hyprland) or the app panicked with "surface isn't supported"
(GNOME). On Linux, pick an adapter that supports the window's surface
and whose PCI ids match a GPU with a connected monitor (from
/sys/class/drm); integrated still wins among those, so hybrid laptops
keep the issue #8 behaviour. WGPU_POWER_PREF still overrides.

* Use the PdfCraft name for the fake sysfs test folder

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-07 21:12:55 -04:00
1 parent bfa17ff3b9
commit 0636b9ca46
1 file changed
+127
+127
View File
@@ -180,6 +180,9 @@ fn write_control_file(path: &str, port: u16, token: &str) -> std::io::Result<()>
/// for a discrete GPU, and on hybrid-graphics laptops (NVIDIA Optimus) the discrete one can lose
/// or corrupt its memory across suspend and screen lock, leaving the window illegible (issue #8).
/// It also saves battery. Machines with one GPU are unaffected.
/// - On Linux, draw on a GPU that a monitor is plugged into. On a desktop whose monitors all hang
/// off the discrete GPU, drawing on the integrated one leaves the window black under Wayland
/// compositors on NVIDIA. Among the GPUs that drive a display, the integrated one still wins.
/// - 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
@@ -188,12 +191,83 @@ fn configure_gpu(native: &mut eframe::NativeOptions) {
let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = &mut native.wgpu_options.wgpu_setup else { return };
if std::env::var_os("WGPU_POWER_PREF").is_none() {
setup.power_preference = eframe::wgpu::PowerPreference::LowPower;
#[cfg(target_os = "linux")]
{
let displays = linux_display_gpus(std::path::Path::new("/sys/class/drm"));
// Without sysfs (containers, remote sessions) the power preference alone decides.
if !displays.is_empty() {
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
.iter()
.map(|a| {
let info = a.get_info();
(info.vendor, info.device, info.device_type)
})
.collect();
pick_adapter(&infos, &displays)
.and_then(|i| usable.get(i))
.map(|a| (*a).clone())
.ok_or_else(|| "no GPU can draw to this window".to_string())
}));
}
}
}
if cfg!(target_os = "windows") && std::env::var_os("WGPU_BACKEND").is_none() {
setup.instance_descriptor.backends = eframe::wgpu::Backends::DX12 | eframe::wgpu::Backends::GL;
}
}
/// PCI `(vendor, device)` ids of the GPUs with a connected monitor, read from the DRM connectors
/// under `drm` (`card1-DP-3/status` is `connected`, `card1/device/{vendor,device}` hold `0x10de`).
#[cfg(target_os = "linux")]
fn linux_display_gpus(drm: &std::path::Path) -> Vec<(u32, u32)> {
let read_hex = |p: std::path::PathBuf| -> Option<u32> {
let s = std::fs::read_to_string(p).ok()?;
u32::from_str_radix(s.trim().trim_start_matches("0x"), 16).ok()
};
let mut gpus = Vec::new();
let Ok(entries) = std::fs::read_dir(drm) else { return gpus };
// A machine has a handful of connectors; the cap only bounds a pathological sysfs.
for entry in entries.flatten().take(256) {
let name = entry.file_name();
let Some((card, _connector)) = name.to_str().and_then(|n| n.split_once('-')) else { continue };
let connected = std::fs::read_to_string(entry.path().join("status")).is_ok_and(|s| s.trim() == "connected");
if !connected {
continue;
}
let device = drm.join(card).join("device");
if let (Some(v), Some(d)) = (read_hex(device.join("vendor")), read_hex(device.join("device")))
&& !gpus.contains(&(v, d))
{
gpus.push((v, d));
}
}
gpus
}
/// Index of the adapter to draw with: one that drives a display (by PCI ids) first, then the most
/// frugal kind — integrated, discrete, other, virtual, software.
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
fn pick_adapter(adapters: &[(u32, u32, eframe::wgpu::DeviceType)], displays: &[(u32, u32)]) -> Option<usize> {
use eframe::wgpu::DeviceType;
adapters
.iter()
.enumerate()
.min_by_key(|(_, (vendor, device, kind))| {
let drives_display = displays.contains(&(*vendor, *device));
let frugality = match kind {
DeviceType::IntegratedGpu => 0,
DeviceType::DiscreteGpu => 1,
DeviceType::Other => 2,
DeviceType::VirtualGpu => 3,
DeviceType::Cpu => 4,
};
(!drives_display, frugality)
})
.map(|(i, _)| i)
}
#[cfg(test)]
mod tests {
#[test]
@@ -212,4 +286,57 @@ mod tests {
assert!(!backends.contains(eframe::wgpu::Backends::VULKAN), "issue #37: {backends:?}");
}
}
const NVIDIA: (u32, u32) = (0x10de, 0x2684);
const AMD_IGPU: (u32, u32) = (0x1002, 0x164e);
fn adapters() -> Vec<(u32, u32, eframe::wgpu::DeviceType)> {
use eframe::wgpu::DeviceType;
vec![(NVIDIA.0, NVIDIA.1, DeviceType::DiscreteGpu), (AMD_IGPU.0, AMD_IGPU.1, DeviceType::IntegratedGpu), (0, 0, DeviceType::Cpu)]
}
#[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(), &[NVIDIA]), Some(0));
}
#[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(), &[NVIDIA, AMD_IGPU]), Some(1));
assert_eq!(super::pick_adapter(&adapters(), &[AMD_IGPU]), Some(1));
}
#[test]
fn pick_adapter_falls_back_to_low_power_without_a_match() {
assert_eq!(super::pick_adapter(&adapters(), &[(0x8086, 0x1234)]), Some(1));
assert_eq!(super::pick_adapter(&[], &[NVIDIA]), None);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_display_gpus_reads_connected_connectors() -> std::io::Result<()> {
let dir = std::env::temp_dir().join(format!("pdfcraft-drm-{}", std::process::id()));
let card = |name: &str, (vendor, device): (u32, u32)| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name).join("device"))?;
std::fs::write(dir.join(name).join("device/vendor"), format!("{vendor:#06x}\n"))?;
std::fs::write(dir.join(name).join("device/device"), format!("{device:#06x}\n"))
};
let connector = |name: &str, status: &str| -> std::io::Result<()> {
std::fs::create_dir_all(dir.join(name))?;
std::fs::write(dir.join(name).join("status"), format!("{status}\n"))
};
card("card1", NVIDIA)?;
card("card2", AMD_IGPU)?;
connector("card1-DP-3", "connected")?;
connector("card1-DP-4", "connected")?;
connector("card2-HDMI-A-1", "disconnected")?;
connector("card2-Writeback-1", "unknown")?;
let gpus = super::linux_display_gpus(&dir);
std::fs::remove_dir_all(&dir)?;
assert_eq!(gpus, vec![NVIDIA]);
assert!(super::linux_display_gpus(std::path::Path::new("/nonexistent/drm")).is_empty());
Ok(())
}
}