Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/cicd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ jobs:
- name: Install Nightly Rust (for aya-ebpf)
uses: dtolnay/rust-toolchain@nightly
with:
toolchain: nightly-2026-08-04
Comment thread
luytan marked this conversation as resolved.
components: rust-src
- name: Install Stable Rust
uses: dtolnay/rust-toolchain@eac0f66a48bc4b70a10b9acd4c4e930f835d95ff # 1.95.0
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,7 @@ result
.pre-commit-config.yaml

# mdbook
book
book

# Nix-specific launcher wrapper
/cardwired
42 changes: 32 additions & 10 deletions crates/cardwire-daemon/src/analyzer/dynamic_analysis.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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<String> {
let desktop_str: String = match env::var("XDG_CURRENT_DESKTOP") {
Expand All @@ -129,15 +133,7 @@ pub async fn get_app_id_wayland(pid: u32) -> Option<String> {
// 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;
}
}
_ => {}
Expand All @@ -146,6 +142,32 @@ pub async fn get_app_id_wayland(pid: u32) -> Option<String> {
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<String> {
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);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<String> {
Expand Down
185 changes: 145 additions & 40 deletions crates/cardwire-daemon/src/analyzer/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -45,12 +50,25 @@ pub struct CardwireAnalyzer {
forced_map: Arc<RwLock<AyaHashMap<aya::maps::MapData, u32, u32>>>,
ebpf_logger: Arc<Mutex<AsyncFd<EbpfLogger<&'static dyn Log>>>>,
xdg_list: Arc<RwLock<HashMap<String, bool>>>,
report_vec: Arc<RwLock<VecDeque<LogEntry>>>,
reported_pids: Arc<RwLock<HashSet<u32>>>,
report_semaphore: Arc<Semaphore>,
signal: Option<SignalEmitter<'static>>,
#[allow(dead_code)]
xdg_folders: Vec<PathBuf>,
}

// 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<RwLock<EbpfBlocker>>) -> anyhow::Result<CardwireAnalyzer> {
pub async fn build(
blocker: Arc<RwLock<EbpfBlocker>>,
report_vec: Arc<RwLock<VecDeque<LogEntry>>>,
signal: Option<SignalEmitter<'static>>,
) -> anyhow::Result<CardwireAnalyzer> {
let mut blocker = blocker.write().await;
let exec_ring = blocker.get_exec_ring()?;
let close_ring = blocker.get_close_ring()?;
Expand Down Expand Up @@ -83,26 +101,26 @@ 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,
})
}
pub async fn run(self) -> anyhow::Result<()> {
// 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 {
Expand All @@ -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) => {
Expand Down Expand Up @@ -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::<ReportEvent>() {
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();
}
}
}
}
Expand Down Expand Up @@ -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::<ReportEvent>() {
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;
Comment thread
luytan marked this conversation as resolved.
} 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();
}
}

Expand Down Expand Up @@ -272,6 +344,39 @@ impl CardwireAnalyzer {
}
}

/// Record a blocked process in the report history and notify listeners
async fn report_blocked(
report_vec: Arc<RwLock<VecDeque<LogEntry>>>,
signal: Option<SignalEmitter<'static>>,
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,
Comment thread
luytan marked this conversation as resolved.
};
{
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<String> {
let cmdline_path = format!("/proc/{}/cmdline", pid);
let cmdline_bytes = match fs::read(&cmdline_path) {
Expand Down
8 changes: 8 additions & 0 deletions crates/cardwire-daemon/src/daemon.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading