diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 87c40718..10333cd2 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -70,6 +70,7 @@ jobs: - name: Install Nightly Rust (for aya-ebpf) uses: dtolnay/rust-toolchain@nightly with: + toolchain: nightly-2026-08-04 components: rust-src - name: Install Stable Rust uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95.0 @@ -99,6 +100,7 @@ jobs: - name: Install Nightly Rust (for aya-ebpf) uses: dtolnay/rust-toolchain@nightly with: + toolchain: nightly-2026-08-04 components: rust-src - name: Install Stable Rust uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95.0 diff --git a/.gitignore b/.gitignore index 8e71c155..76de09bd 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ result .pre-commit-config.yaml # mdbook -book \ No newline at end of file +book + +# Nix-specific launcher wrapper +/cardwired diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index 1ff25027..4059b9e8 100644 --- a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs @@ -2,7 +2,7 @@ //! - gamemoderun analysis //! - library analysis use std::{ - collections::HashMap, env, fs, path::{Path, PathBuf}, time::Duration + collections::HashMap, env, fs, path::{Path, PathBuf}, time::{Duration, Instant} }; use tokio::{ @@ -116,6 +116,10 @@ pub fn check_gpu_env(environ: &[u8]) -> bool { false } +/// How long a reported pid keeps getting retried before falling back to the +/// process name +pub const APP_ID_LOOKUP_TIMEOUT: Duration = Duration::from_millis(2000); + /// pid to wayland app id, needs to be async to wait pub async fn get_app_id_wayland(pid: u32) -> Option { let desktop_str: String = match env::var("XDG_CURRENT_DESKTOP") { @@ -129,15 +133,7 @@ pub async fn get_app_id_wayland(pid: u32) -> Option { // We use the niri ipc to get the window real name Desktop::Niri => { if let Some(socket_path) = find_niri_socket() { - let max_retries = 40; - let delay = Duration::from_millis(50); - for _ in 0..max_retries { - let app_id = query_niri_window(&socket_path, pid).await; - if app_id.is_some() { - return app_id; - } - tokio::time::sleep(delay).await; - } + return query_niri_window(&socket_path, pid).await; } } _ => {} @@ -146,6 +142,32 @@ pub async fn get_app_id_wayland(pid: u32) -> Option { None } +/// Retry `get_app_id_wayland` until the lookup timeout expires, the window +/// of a freshly launched process can take a moment to be mapped by the +/// compositor. Breaks early if the process exits. +pub async fn get_app_id_wayland_with_retry(pid: u32) -> Option { + let deadline = Instant::now() + APP_ID_LOOKUP_TIMEOUT; + let delay = Duration::from_millis(50); + loop { + // The process is gone, we will never find a window for it + if !Path::new(&format!("/proc/{}", pid)).exists() { + return None; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + if let Ok(Some(app_id)) = tokio::time::timeout(remaining, get_app_id_wayland(pid)).await { + return Some(app_id); + } + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + tokio::time::sleep(delay.min(remaining)).await; + } +} + /// Query niri IPC for a window's app_id by pid /// Returns None on any error async fn query_niri_window(socket_path: &Path, pid: u32) -> Option { diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index be35e433..62ae634f 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -2,15 +2,20 @@ use aya::maps::{HashMap as AyaHashMap, RingBuf}; use aya_log::EbpfLogger; use cardwire_ebpf_userspace::EbpfBlocker; use log::{Log, debug, error, info, warn}; -use std::{collections::HashMap, fs, path::PathBuf, ptr, sync::Arc}; +use std::{ + collections::{HashMap, HashSet, VecDeque}, fs, path::PathBuf, ptr, sync::Arc, time::SystemTime +}; use tokio::{ - io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock}, task, time::Instant + io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, Semaphore}, task, time::Instant }; - -use crate::analyzer::{ - dynamic_analysis::{ - check_env, check_fdo_app_id, check_for_flatpak_run, check_gpu_env, check_steam_environ, desktop_supports_switcheroo, get_app_id_wayland - }, static_analysis +use zbus::object_server::SignalEmitter; + +use crate::{ + analyzer::{ + dynamic_analysis::{ + check_env, check_fdo_app_id, check_for_flatpak_run, check_gpu_env, check_steam_environ, desktop_supports_switcheroo, get_app_id_wayland_with_retry + }, static_analysis + }, interface::{LogEntry, LoggerInterfaceSignals} }; #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -45,12 +50,25 @@ pub struct CardwireAnalyzer { forced_map: Arc>>, ebpf_logger: Arc>>>, xdg_list: Arc>>, + report_vec: Arc>>, + reported_pids: Arc>>, + report_semaphore: Arc, + signal: Option>, #[allow(dead_code)] xdg_folders: Vec, } +// Bound the number of concurrent report tasks +const REPORT_SEMAPHORE_PERMITS: usize = 32; +// Max entries kept in the report history +const MAX_REPORT_ENTRIES: usize = 4096; + impl CardwireAnalyzer { - pub async fn build(blocker: Arc>) -> anyhow::Result { + pub async fn build( + blocker: Arc>, + report_vec: Arc>>, + signal: Option>, + ) -> anyhow::Result { let mut blocker = blocker.write().await; let exec_ring = blocker.get_exec_ring()?; let close_ring = blocker.get_close_ring()?; @@ -83,6 +101,10 @@ impl CardwireAnalyzer { forced_map, ebpf_logger, xdg_list, + report_vec, + reported_pids: Arc::new(RwLock::new(HashSet::new())), + report_semaphore: Arc::new(Semaphore::new(REPORT_SEMAPHORE_PERMITS)), + signal, xdg_folders, }) } @@ -90,19 +112,15 @@ impl CardwireAnalyzer { // Clone the Arcs and Sender to move into the background task let exec_arc = self.exec_ring.clone(); let close_arc = self.close_ring.clone(); - let report_arc = self.report_ring.clone(); let logger_arc = self.ebpf_logger.clone(); + // Lock the buffers once let mut exec_ring = exec_arc.lock().await; let mut close_ring = close_arc.lock().await; - let mut report_ring = report_arc.lock().await; - - // Used to prevent duplicated logs burst - let mut previous_reported_pid = 0; let shared_self = Arc::new(self); - // spawn the logger in it's own thread + // spawn the ebpf-logger in it's own thread task::spawn(async move { let mut ebpf_logger = logger_arc.lock().await; loop { @@ -118,6 +136,10 @@ impl CardwireAnalyzer { } }); + // spawn the blocked event report in it's own thread + let shared_self_report = Arc::clone(&shared_self); + task::spawn(async move { shared_self_report.report_logger().await }); + loop { tokio::select! { Ok(mut guard) = exec_ring.ready_mut(Interest::READABLE) => { @@ -153,26 +175,6 @@ impl CardwireAnalyzer { guard.clear_ready(); } } - Ok(mut guard) = report_ring.ready_mut(Interest::READABLE) => { - while let Some(item) = guard.get_inner_mut().next() { - if item.len() < std::mem::size_of::() { - debug!("Skipping malformed report event. Size: {}", item.len()); - continue; - } - let event = unsafe { ptr::read_unaligned(item.as_ptr() as *const ReportEvent) }; - // only log if we didn't see the pid before - if event.pid != previous_reported_pid { - previous_reported_pid = event.pid; - task::spawn(async move { - if let Some(app_id) = get_app_id_wayland(event.pid).await { - // use dGPU term instead of GPU, smart mode is only avaible on hybrid setups - info!("{}[{}] tried to access the dGPU (blocked by cardwire)", app_id, event.pid); - } - }); - } - } - guard.clear_ready(); - } } } } @@ -215,13 +217,83 @@ impl CardwireAnalyzer { } } async fn spawn_remove_analyzer(&self, event: CloseEvent) -> () { - let mut pid_map = self.pid_map.write().await; - if pid_map.remove(&event.pid).is_ok() { - debug!("REMOVE: pid: {}", event.pid); + { + let mut pid_map = self.pid_map.write().await; + if pid_map.remove(&event.pid).is_ok() { + debug!("REMOVE: pid: {}", event.pid); + } } - let mut forced_map = self.forced_map.write().await; - if forced_map.remove(&event.pid).is_ok() { - debug!("REMOVE FORCED: pid: {}", event.pid); + { + let mut forced_map = self.forced_map.write().await; + if forced_map.remove(&event.pid).is_ok() { + debug!("REMOVE FORCED: pid: {}", event.pid); + } + } + { + let mut reported_pid_map = self.reported_pids.write().await; + if reported_pid_map.remove(&event.pid) { + debug!("REMOVE REPORTED: pid: {}", event.pid); + } + } + } + + async fn report_logger(&self) -> () { + let report_arc = self.report_ring.clone(); + let mut report_ring = report_arc.lock().await; + let report_vec = self.report_vec.clone(); + + // Used to prevent duplicated logs burst + let reported_pids_arc = self.reported_pids.clone(); + let report_semaphore = self.report_semaphore.clone(); + loop { + let mut guard = match report_ring.ready_mut(Interest::READABLE).await { + Ok(guard) => guard, + Err(err) => { + error!("failed to get report logger guard: {}", err); + return; + } + }; + while let Some(item) = guard.get_inner_mut().next() { + if item.len() < std::mem::size_of::() { + debug!("Skipping malformed report event. Size: {}", item.len()); + continue; + } + let event = unsafe { ptr::read_unaligned(item.as_ptr() as *const ReportEvent) }; + // only log if we didn't see the pid recently + { + let mut reported_pids = reported_pids_arc.write().await; + if reported_pids.contains(&event.pid) { + continue; + } else { + reported_pids.insert(event.pid); + } + } + // Bound the number of concurrent report tasks, this prevent exausting the process + // FD limits + if let Ok(permit) = report_semaphore.clone().acquire_owned().await { + // Spawn in another task to prevent blocking the report logger while + // fetching informations about this process + let report_vec = report_vec.clone(); + let signal = self.signal.clone(); + task::spawn(async move { + let _permit = permit; + if let Some(app_id) = get_app_id_wayland_with_retry(event.pid).await { + report_blocked(report_vec, signal, event.pid, app_id.clone(), app_id) + .await; + } else if let Some(process_name) = get_real_process_name(event.pid) { + report_blocked( + report_vec, + signal, + event.pid, + process_name, + String::new(), + ) + .await; + } + }); + } + } + guard.clear_ready(); } } @@ -272,6 +344,39 @@ impl CardwireAnalyzer { } } +/// Record a blocked process in the report history and notify listeners +async fn report_blocked( + report_vec: Arc>>, + signal: Option>, + pid: u32, + name: String, + wayland_app_id: String, +) { + let log_entry = LogEntry { + timestamp: SystemTime::now(), + pid, + comm: name.clone(), + gpu_id: 1, + wayland_app_id, + }; + { + let mut report_vec = report_vec.write().await; + report_vec.push_back(log_entry.clone()); + while report_vec.len() > MAX_REPORT_ENTRIES { + report_vec.pop_front(); + } + } + info!( + "{}[{}] tried to access the dGPU (blocked by cardwire)", + name, pid + ); + if let Some(signal) = signal + && let Err(e) = LoggerInterfaceSignals::process_blocked_changed(&signal, log_entry).await + { + error!("failed to emit process_blocked_changed: {}", e); + } +} + fn get_real_process_name(pid: u32) -> Option { let cmdline_path = format!("/proc/{}/cmdline", pid); let cmdline_bytes = match fs::read(&cmdline_path) { diff --git a/crates/cardwire-daemon/src/daemon.rs b/crates/cardwire-daemon/src/daemon.rs index d6fe6203..27e9ca6a 100644 --- a/crates/cardwire-daemon/src/daemon.rs +++ b/crates/cardwire-daemon/src/daemon.rs @@ -112,6 +112,14 @@ async fn spawn_dbus_api( power_tasks.insert(*id, handle); } } + // Cardwire logger + object_server + .at(path, daemon.logger_interface.clone()) + .await?; + let logger_ref = object_server + .interface::<_, crate::interface::LoggerInterface>(path) + .await?; + daemon.logger_signal = Some(logger_ref.signal_emitter().clone()); drop(power_tasks); // drop gpu list to prevent deadlock drop(gpu_interfaces); diff --git a/crates/cardwire-daemon/src/interface/logger.rs b/crates/cardwire-daemon/src/interface/logger.rs new file mode 100644 index 00000000..34299200 --- /dev/null +++ b/crates/cardwire-daemon/src/interface/logger.rs @@ -0,0 +1,41 @@ +use std::{collections::VecDeque, sync::Arc, time::SystemTime}; + +use tokio::sync::RwLock; + +use zbus::{fdo, interface, object_server::SignalEmitter}; + +#[derive(Debug, Clone, zbus::zvariant::Type, serde::Serialize)] +pub struct LogEntry { + pub timestamp: SystemTime, + pub pid: u32, + pub comm: String, + pub gpu_id: u32, + pub wayland_app_id: String, +} + +#[derive(Clone)] +pub struct LoggerInterface { + pub report_logs: Arc>>, +} + +impl LoggerInterface { + pub fn build() -> Self { + Self { + report_logs: Arc::new(RwLock::new(VecDeque::with_capacity(4096))), + } + } +} + +#[interface(name = "org.opengamingcollective.cardwire.Logger")] +impl LoggerInterface { + pub async fn process_blocked(&self) -> fdo::Result> { + let vec = self.report_logs.read().await; + Ok(vec.clone()) + } + + #[zbus(signal)] + pub async fn process_blocked_changed( + emitter: &SignalEmitter<'_>, + log: LogEntry, + ) -> zbus::Result<()>; +} diff --git a/crates/cardwire-daemon/src/interface/mod.rs b/crates/cardwire-daemon/src/interface/mod.rs index 9a590303..91a28347 100644 --- a/crates/cardwire-daemon/src/interface/mod.rs +++ b/crates/cardwire-daemon/src/interface/mod.rs @@ -1,11 +1,13 @@ mod config; mod debug; mod gpu; +mod logger; mod mode; mod switcheroo; pub use config::{ConfigInterface, ConfigMemory}; pub use debug::DebugInterface; pub use gpu::{GpuInterface, GpuInterfaceSignals}; +pub use logger::{LogEntry, LoggerInterface, LoggerInterfaceSignals}; pub use mode::{ModeInterface, Modes}; pub use switcheroo::SwitcherooInterface; diff --git a/crates/cardwire-daemon/src/models.rs b/crates/cardwire-daemon/src/models.rs index 10d09796..b674242c 100644 --- a/crates/cardwire-daemon/src/models.rs +++ b/crates/cardwire-daemon/src/models.rs @@ -3,7 +3,7 @@ use crate::{ analyzer::CardwireAnalyzer, core::{ gpu::{GpuEnumerator, GpuVendor}, inode::exp_nvidia_inodes, pci::{self} }, file::{CardwireConfig, CardwireGpuState, CardwireModeState}, interface::{ - ConfigInterface, ConfigMemory, DebugInterface, GpuInterface, ModeInterface, Modes, SwitcherooInterface + ConfigInterface, ConfigMemory, DebugInterface, GpuInterface, LoggerInterface, ModeInterface, Modes, SwitcherooInterface }, tasks }; use anyhow::{Context, Result}; @@ -12,7 +12,7 @@ use log::error; use std::{collections::BTreeMap, sync::Arc}; use tokio::{sync::RwLock, task}; use zbus::{ - fdo::{self}, interface, object_server::InterfaceRef + fdo::{self}, interface, object_server::{InterfaceRef, SignalEmitter} }; /// Contain the variable used by the daemon in daemon.rs @@ -33,6 +33,8 @@ pub struct DaemonManager { pub config_interface: ConfigInterface, pub debug_interface: DebugInterface, pub switcheroo_interface: SwitcherooInterface, + pub logger_interface: LoggerInterface, + pub logger_signal: Option>, pub inner: DaemonInner, } @@ -87,6 +89,8 @@ impl DaemonManager { ) .await?; + let logger_interface = LoggerInterface::build(); + Ok(Self { mode_interface: mode_interface.clone(), gpu_interfaces: Arc::clone(&gpu_interfaces), @@ -106,6 +110,8 @@ impl DaemonManager { Arc::clone(&power_tasks), )?, switcheroo_interface: SwitcherooInterface::build(Arc::clone(&gpu_interfaces)), + logger_interface, + logger_signal: None, inner: DaemonInner { mode_state: Arc::clone(&mode_state), gpu_state: Arc::clone(&gpu_state), @@ -284,8 +290,10 @@ impl DaemonManager { } pub fn run_analyzer(&self) -> impl Future> + 'static { let blocker = Arc::clone(&self.inner.blocker); + let logger = Arc::clone(&self.logger_interface.report_logs); + let signal = self.logger_signal.clone(); async move { - let cardwire_analyzer = CardwireAnalyzer::build(Arc::clone(&blocker)) + let cardwire_analyzer = CardwireAnalyzer::build(blocker, logger, signal) .await .map_err(|err| { error!("Failed to build CardwireAnalyzer: {}", err); diff --git a/crates/cardwire-ebpf-userspace/build.rs b/crates/cardwire-ebpf-userspace/build.rs index 1a09f788..1f960f63 100644 --- a/crates/cardwire-ebpf-userspace/build.rs +++ b/crates/cardwire-ebpf-userspace/build.rs @@ -23,5 +23,9 @@ fn main() -> anyhow::Result<()> { .as_str(), ..Default::default() }; - aya_build::build_ebpf([ebpf_package], Toolchain::default()) + // The prebuilt bpf-linker v0.10.4 release bundles LLVM 22 and cannot link LLVM-23 bitcode + // emitted by nightlies from 2026-08-05 onward (`ERROR llvm: Invalid record`). Pin the eBPF + // build to the last compatible nightly. bump this once bpf-linker supports LLVM 23. + const EBPF_NIGHTLY: &str = "nightly-2026-08-04"; + aya_build::build_ebpf([ebpf_package], Toolchain::Custom(EBPF_NIGHTLY)) }