From 35ec2e8d0b34dadffba2b51d0c145d79132e737a Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 16:56:39 +0200 Subject: [PATCH 01/12] refactor(cardwired): move report logger into it's own task and report non-desktop processes --- crates/cardwire-daemon/src/analyzer/models.rs | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index be35e433..35fd743a 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -95,14 +95,13 @@ impl CardwireAnalyzer { // 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 +117,47 @@ impl CardwireAnalyzer { } }); + // spawn the blocked event report in it's own thread + task::spawn(async move { + let mut report_ring = report_arc.lock().await; + 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 before + if event.pid != previous_reported_pid { + previous_reported_pid = event.pid; + // Spawn in another task to prevent blocking the report logger while + // fetching informations about this process + task::spawn(async move { + if let Some(app_id) = get_app_id_wayland(event.pid).await { + info!( + "{}[{}] tried to access the dGPU (blocked by cardwire)", + app_id, event.pid + ); + } else if let Some(process_name) = get_real_process_name(event.pid) { + info!( + "{}[{}] tried to access the dGPU (blocked by cardwire)", + process_name, event.pid + ); + } + }); + } + } + guard.clear_ready(); + } + }); + loop { tokio::select! { Ok(mut guard) = exec_ring.ready_mut(Interest::READABLE) => { @@ -153,26 +193,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(); - } } } } From c49823912d007651a965fc9e64ed6d8bd7112503 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 17:43:02 +0200 Subject: [PATCH 02/12] feat(cardwired): report blocked apps using a dbus signal --- crates/cardwire-daemon/src/analyzer/models.rs | 66 +++++++++++++++++-- crates/cardwire-daemon/src/daemon.rs | 8 +++ crates/cardwire-daemon/src/interface/mod.rs | 2 + crates/cardwire-daemon/src/models.rs | 14 +++- 4 files changed, 80 insertions(+), 10 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 35fd743a..7656a475 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, VecDeque}, fs, path::PathBuf, ptr, sync::Arc, time::SystemTime +}; use tokio::{ io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock}, 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 + }, static_analysis + }, interface::{LogEntry, LoggerInterfaceSignals} }; #[repr(C)] #[derive(Debug, Copy, Clone)] @@ -45,12 +50,18 @@ pub struct CardwireAnalyzer { forced_map: Arc>>, ebpf_logger: Arc>>>, xdg_list: Arc>>, + report_vec: Arc>>, + signal: Option>, #[allow(dead_code)] xdg_folders: Vec, } 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 +94,8 @@ impl CardwireAnalyzer { forced_map, ebpf_logger, xdg_list, + report_vec, + signal, xdg_folders, }) } @@ -92,6 +105,8 @@ impl CardwireAnalyzer { let close_arc = self.close_ring.clone(); let report_arc = self.report_ring.clone(); let logger_arc = self.ebpf_logger.clone(); + let report_vec = self.report_vec.clone(); + // Lock the buffers once let mut exec_ring = exec_arc.lock().await; let mut close_ring = close_arc.lock().await; @@ -118,6 +133,7 @@ impl CardwireAnalyzer { }); // spawn the blocked event report in it's own thread + let shared_self_report = Arc::clone(&shared_self); task::spawn(async move { let mut report_ring = report_arc.lock().await; loop { @@ -139,17 +155,53 @@ impl CardwireAnalyzer { previous_reported_pid = event.pid; // Spawn in another task to prevent blocking the report logger while // fetching informations about this process + let report_vec = report_vec.clone(); + let signal = shared_self_report.signal.clone(); task::spawn(async move { if let Some(app_id) = get_app_id_wayland(event.pid).await { + let mut report_vec = report_vec.write().await; + let log_entry = LogEntry { + timestamp: SystemTime::now(), + pid: event.pid, + comm: app_id.clone(), + gpu_id: 1, + }; + report_vec.push_back(log_entry.clone()); info!( "{}[{}] tried to access the dGPU (blocked by cardwire)", app_id, event.pid ); + if let Some(signal) = signal { + if let Err(e) = LoggerInterfaceSignals::process_blocked_changed( + &signal, log_entry, + ) + .await + { + error!("failed to emit process_blocked_changed: {}", e); + } + } } else if let Some(process_name) = get_real_process_name(event.pid) { + let mut report_vec = report_vec.write().await; + let log_entry = LogEntry { + timestamp: SystemTime::now(), + pid: event.pid, + comm: process_name.clone(), + gpu_id: 1, + }; + report_vec.push_back(log_entry.clone()); info!( "{}[{}] tried to access the dGPU (blocked by cardwire)", process_name, event.pid ); + if let Some(signal) = signal { + if let Err(e) = LoggerInterfaceSignals::process_blocked_changed( + &signal, log_entry, + ) + .await + { + error!("failed to emit process_blocked_changed: {}", e); + } + } } }); } 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/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); From 95d9f718a62870adb7bbb94a9e890beeaad841d7 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 17:45:07 +0200 Subject: [PATCH 03/12] chore(cardwired): forgot the interface --- .../cardwire-daemon/src/interface/logger.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 crates/cardwire-daemon/src/interface/logger.rs diff --git a/crates/cardwire-daemon/src/interface/logger.rs b/crates/cardwire-daemon/src/interface/logger.rs new file mode 100644 index 00000000..8ad8d1d6 --- /dev/null +++ b/crates/cardwire-daemon/src/interface/logger.rs @@ -0,0 +1,39 @@ +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, +} + +#[derive(Clone)] +pub struct LoggerInterface { + pub report_logs: Arc>>, +} + +impl LoggerInterface { + pub fn build() -> Self { + Self { + report_logs: Arc::new(RwLock::new(VecDeque::new())), + } + } +} + +#[interface(name = "org.opengamingcollective.cardwire.Logger")] +impl LoggerInterface { + pub async fn process_blocked(&self) -> fdo::Result { + Err(fdo::Error::AccessDenied("only use signal".to_string())) + } + + #[zbus(signal)] + pub async fn process_blocked_changed( + emitter: &SignalEmitter<'_>, + log: LogEntry, + ) -> zbus::Result<()>; +} From 88befc4181e7b3d091870f80e82bc64284c3ea78 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 18:04:35 +0200 Subject: [PATCH 04/12] feat(cardwired): add wayland app id to report log --- crates/cardwire-daemon/src/analyzer/models.rs | 2 ++ crates/cardwire-daemon/src/interface/logger.rs | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 7656a475..00b27f8d 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -165,6 +165,7 @@ impl CardwireAnalyzer { pid: event.pid, comm: app_id.clone(), gpu_id: 1, + wayland_app_id: app_id.clone(), }; report_vec.push_back(log_entry.clone()); info!( @@ -187,6 +188,7 @@ impl CardwireAnalyzer { pid: event.pid, comm: process_name.clone(), gpu_id: 1, + wayland_app_id: String::new(), }; report_vec.push_back(log_entry.clone()); info!( diff --git a/crates/cardwire-daemon/src/interface/logger.rs b/crates/cardwire-daemon/src/interface/logger.rs index 8ad8d1d6..bc67d891 100644 --- a/crates/cardwire-daemon/src/interface/logger.rs +++ b/crates/cardwire-daemon/src/interface/logger.rs @@ -10,6 +10,7 @@ pub struct LogEntry { pub pid: u32, pub comm: String, pub gpu_id: u32, + pub wayland_app_id: String, } #[derive(Clone)] @@ -27,8 +28,9 @@ impl LoggerInterface { #[interface(name = "org.opengamingcollective.cardwire.Logger")] impl LoggerInterface { - pub async fn process_blocked(&self) -> fdo::Result { - Err(fdo::Error::AccessDenied("only use signal".to_string())) + pub async fn process_blocked(&self) -> fdo::Result> { + let vec = self.report_logs.read().await; + Ok(vec.clone()) } #[zbus(signal)] From c247292c1380e6657bc6dae929e9b1a10ea919f2 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 18:05:30 +0200 Subject: [PATCH 05/12] style(cardwired): nested if else --- crates/cardwire-daemon/src/analyzer/models.rs | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 00b27f8d..7091c680 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -172,14 +172,13 @@ impl CardwireAnalyzer { "{}[{}] tried to access the dGPU (blocked by cardwire)", app_id, event.pid ); - if let Some(signal) = signal { - if let Err(e) = LoggerInterfaceSignals::process_blocked_changed( + if let Some(signal) = signal + && let Err(e) = LoggerInterfaceSignals::process_blocked_changed( &signal, log_entry, ) .await - { - error!("failed to emit process_blocked_changed: {}", e); - } + { + error!("failed to emit process_blocked_changed: {}", e); } } else if let Some(process_name) = get_real_process_name(event.pid) { let mut report_vec = report_vec.write().await; @@ -195,14 +194,13 @@ impl CardwireAnalyzer { "{}[{}] tried to access the dGPU (blocked by cardwire)", process_name, event.pid ); - if let Some(signal) = signal { - if let Err(e) = LoggerInterfaceSignals::process_blocked_changed( + if let Some(signal) = signal + && let Err(e) = LoggerInterfaceSignals::process_blocked_changed( &signal, log_entry, ) .await - { - error!("failed to emit process_blocked_changed: {}", e); - } + { + error!("failed to emit process_blocked_changed: {}", e); } } }); From 0baee18318db901c150f806ed22100d93fa0ba53 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 19:41:32 +0200 Subject: [PATCH 06/12] chore: ignore my nix wrapper --- .gitignore | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 8e71c155..320cb8ed 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,7 @@ result .pre-commit-config.yaml # mdbook -book \ No newline at end of file +book + +# Machine-specific launcher wrapper +cardwired \ No newline at end of file From 140f5048ef652a3a3fa415354994d3b18a23d81e Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 19:49:13 +0200 Subject: [PATCH 07/12] fix(cardwired): prevent DoS and add limit to Vec --- .../src/analyzer/dynamic_analysis.rs | 37 +++- crates/cardwire-daemon/src/analyzer/models.rs | 186 ++++++++++-------- .../cardwire-daemon/src/interface/logger.rs | 2 +- 3 files changed, 132 insertions(+), 93 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index 1ff25027..575b3426 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,27 @@ 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; + } + if let Some(app_id) = get_app_id_wayland(pid).await { + return Some(app_id); + } + if Instant::now() >= deadline { + return None; + } + tokio::time::sleep(delay).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 7091c680..9557614f 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -6,14 +6,14 @@ use std::{ collections::{HashMap, 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 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 + 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} }; @@ -51,11 +51,19 @@ pub struct CardwireAnalyzer { ebpf_logger: Arc>>>, xdg_list: Arc>>, report_vec: 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; +// A pid already in the report history is considered a duplicate and is skipped +const REPORT_DEDUP_MAX_PIDS: usize = 4096; +// Max entries kept in the report history +const MAX_REPORT_ENTRIES: usize = 4096; + impl CardwireAnalyzer { pub async fn build( blocker: Arc>, @@ -95,6 +103,7 @@ impl CardwireAnalyzer { ebpf_logger, xdg_list, report_vec, + report_semaphore: Arc::new(Semaphore::new(REPORT_SEMAPHORE_PERMITS)), signal, xdg_folders, }) @@ -103,17 +112,12 @@ 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(); - let report_vec = self.report_vec.clone(); // Lock the buffers once let mut exec_ring = exec_arc.lock().await; let mut close_ring = close_arc.lock().await; - // Used to prevent duplicated logs burst - let mut previous_reported_pid = 0; - let shared_self = Arc::new(self); // spawn the ebpf-logger in it's own thread @@ -134,81 +138,7 @@ impl CardwireAnalyzer { // spawn the blocked event report in it's own thread let shared_self_report = Arc::clone(&shared_self); - task::spawn(async move { - let mut report_ring = report_arc.lock().await; - 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 before - if event.pid != previous_reported_pid { - previous_reported_pid = event.pid; - // Spawn in another task to prevent blocking the report logger while - // fetching informations about this process - let report_vec = report_vec.clone(); - let signal = shared_self_report.signal.clone(); - task::spawn(async move { - if let Some(app_id) = get_app_id_wayland(event.pid).await { - let mut report_vec = report_vec.write().await; - let log_entry = LogEntry { - timestamp: SystemTime::now(), - pid: event.pid, - comm: app_id.clone(), - gpu_id: 1, - wayland_app_id: app_id.clone(), - }; - report_vec.push_back(log_entry.clone()); - info!( - "{}[{}] tried to access the dGPU (blocked by cardwire)", - app_id, event.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); - } - } else if let Some(process_name) = get_real_process_name(event.pid) { - let mut report_vec = report_vec.write().await; - let log_entry = LogEntry { - timestamp: SystemTime::now(), - pid: event.pid, - comm: process_name.clone(), - gpu_id: 1, - wayland_app_id: String::new(), - }; - report_vec.push_back(log_entry.clone()); - info!( - "{}[{}] tried to access the dGPU (blocked by cardwire)", - process_name, event.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); - } - } - }); - } - } - guard.clear_ready(); - } - }); + task::spawn(async move { shared_self_report.report_logger().await }); loop { tokio::select! { @@ -297,6 +227,65 @@ impl CardwireAnalyzer { } } + 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, a pid is only reported once + // until it is evicted from the history by newer entries + let mut reported_pids: VecDeque = VecDeque::new(); + 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 + if reported_pids.contains(&event.pid) { + continue; + } + reported_pids.push_back(event.pid); + if reported_pids.len() > REPORT_DEDUP_MAX_PIDS { + reported_pids.pop_front(); + } + // Bound the number of concurrent report tasks + 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(); + } + } + /// Default app are blocked, try to find if it's a game or a gpu intensive app, the u8 is the /// gpu id async fn evaluate_app(&self, pid: u32, comm: &str) -> Option<(bool, PidType, u32)> { @@ -344,6 +333,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/interface/logger.rs b/crates/cardwire-daemon/src/interface/logger.rs index bc67d891..34299200 100644 --- a/crates/cardwire-daemon/src/interface/logger.rs +++ b/crates/cardwire-daemon/src/interface/logger.rs @@ -21,7 +21,7 @@ pub struct LoggerInterface { impl LoggerInterface { pub fn build() -> Self { Self { - report_logs: Arc::new(RwLock::new(VecDeque::new())), + report_logs: Arc::new(RwLock::new(VecDeque::with_capacity(4096))), } } } From 20408ffe059966db70e5ff60d89200cd90205f20 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 20:38:56 +0200 Subject: [PATCH 08/12] fix(cardwired): add tokio timeout --- crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index 575b3426..58137a2f 100644 --- a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs @@ -153,12 +153,13 @@ pub async fn get_app_id_wayland_with_retry(pid: u32) -> Option { if !Path::new(&format!("/proc/{}", pid)).exists() { return None; } - if let Some(app_id) = get_app_id_wayland(pid).await { - return Some(app_id); - } - if Instant::now() >= deadline { + 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); + } tokio::time::sleep(delay).await; } } From 63141b11de79f233b7a83f5f530fee57e05bec22 Mon Sep 17 00:00:00 2001 From: luytan Date: Wed, 5 Aug 2026 20:44:23 +0200 Subject: [PATCH 09/12] fix(cardwired): loop budget --- crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs index 58137a2f..4059b9e8 100644 --- a/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs +++ b/crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs @@ -160,7 +160,11 @@ pub async fn get_app_id_wayland_with_retry(pid: u32) -> Option { if let Ok(Some(app_id)) = tokio::time::timeout(remaining, get_app_id_wayland(pid)).await { return Some(app_id); } - tokio::time::sleep(delay).await; + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return None; + } + tokio::time::sleep(delay.min(remaining)).await; } } From b0bf493ac5d1c8ebd647b0b7312d2c5aa4171132 Mon Sep 17 00:00:00 2001 From: luytan Date: Thu, 6 Aug 2026 10:55:36 +0200 Subject: [PATCH 10/12] refactor(cardwired): use a hashset for caching instead of a vec --- crates/cardwire-daemon/src/analyzer/models.rs | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/crates/cardwire-daemon/src/analyzer/models.rs b/crates/cardwire-daemon/src/analyzer/models.rs index 9557614f..62ae634f 100644 --- a/crates/cardwire-daemon/src/analyzer/models.rs +++ b/crates/cardwire-daemon/src/analyzer/models.rs @@ -3,7 +3,7 @@ use aya_log::EbpfLogger; use cardwire_ebpf_userspace::EbpfBlocker; use log::{Log, debug, error, info, warn}; use std::{ - collections::{HashMap, VecDeque}, fs, path::PathBuf, ptr, sync::Arc, time::SystemTime + collections::{HashMap, HashSet, VecDeque}, fs, path::PathBuf, ptr, sync::Arc, time::SystemTime }; use tokio::{ io::{Interest, unix::AsyncFd}, sync::{Mutex, RwLock, Semaphore}, task, time::Instant @@ -51,6 +51,7 @@ pub struct CardwireAnalyzer { ebpf_logger: Arc>>>, xdg_list: Arc>>, report_vec: Arc>>, + reported_pids: Arc>>, report_semaphore: Arc, signal: Option>, #[allow(dead_code)] @@ -59,8 +60,6 @@ pub struct CardwireAnalyzer { // Bound the number of concurrent report tasks const REPORT_SEMAPHORE_PERMITS: usize = 32; -// A pid already in the report history is considered a duplicate and is skipped -const REPORT_DEDUP_MAX_PIDS: usize = 4096; // Max entries kept in the report history const MAX_REPORT_ENTRIES: usize = 4096; @@ -103,6 +102,7 @@ impl CardwireAnalyzer { 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, @@ -217,13 +217,23 @@ 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); + } } } @@ -232,9 +242,8 @@ impl CardwireAnalyzer { let mut report_ring = report_arc.lock().await; let report_vec = self.report_vec.clone(); - // Used to prevent duplicated logs burst, a pid is only reported once - // until it is evicted from the history by newer entries - let mut reported_pids: VecDeque = VecDeque::new(); + // 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 { @@ -251,14 +260,16 @@ impl CardwireAnalyzer { } let event = unsafe { ptr::read_unaligned(item.as_ptr() as *const ReportEvent) }; // only log if we didn't see the pid recently - if reported_pids.contains(&event.pid) { - continue; - } - reported_pids.push_back(event.pid); - if reported_pids.len() > REPORT_DEDUP_MAX_PIDS { - reported_pids.pop_front(); + { + 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 + // 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 From 63537502496598123dd2a7ee4a3d94bf72012993 Mon Sep 17 00:00:00 2001 From: luytan Date: Thu, 6 Aug 2026 12:18:06 +0200 Subject: [PATCH 11/12] ci: pin nightly --- .github/workflows/cicd.yml | 2 ++ crates/cardwire-ebpf-userspace/build.rs | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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/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)) } From e882ebd8690b118180e27210b96366080aaf5968 Mon Sep 17 00:00:00 2001 From: luytan Date: Thu, 6 Aug 2026 12:33:40 +0200 Subject: [PATCH 12/12] chore: only block cardwired if at the root of the repo --- .gitignore | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index 320cb8ed..76de09bd 100644 --- a/.gitignore +++ b/.gitignore @@ -24,5 +24,5 @@ result # mdbook book -# Machine-specific launcher wrapper -cardwired \ No newline at end of file +# Nix-specific launcher wrapper +/cardwired