#![cfg(not(target_arch = "wasm32"))]
use crate::command::{Command, Response};
use exfiltrate_internal::args::{ArgKind, ArgSpec, ParsedArgs};
pub(crate) struct Threads;
static ARGS: &[ArgSpec] = &[ArgSpec::flag(
"filter",
"only show threads whose name contains this substring",
ArgKind::String,
)];
impl Command for Threads {
fn name(&self) -> &'static str {
"threads"
}
fn short_description(&self) -> &'static str {
"Lists the process's threads and their names. Use this on a program that appears wedged."
}
fn full_description(&self) -> &'static str {
"Lists the threads of the process being debugged.
Two sources are merged:
* On Linux, /proc/self/task, which covers every thread in the process including
ones spawned by C libraries. This is the complete list.
* Threads that called `exfiltrate::register_thread`, which is the only source on a
target without /proc, and the only one that can carry a note about what the
thread is for. Registered entries are removed when the thread exits.
Stacks are not included. Walking another thread's stack needs the thread suspended,
which is not something a library can do to its own process portably; if you need
stacks, a real debugger is the right tool."
}
fn args(&self) -> &'static [ArgSpec] {
ARGS
}
fn execute(&self, args: Vec<String>) -> Result<Response, Response> {
let parsed = ParsedArgs::parse(self.args(), args).map_err(Response::String)?;
let filter = parsed.get("filter");
Ok(render(&os_threads(), &crate::threads::registered(), filter).into())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OsThread {
tid: String,
name: String,
}
fn os_threads() -> Vec<OsThread> {
#[cfg(target_os = "linux")]
{
let Ok(entries) = std::fs::read_dir("/proc/self/task") else {
return Vec::new();
};
let mut threads: Vec<OsThread> = entries
.filter_map(|entry| entry.ok())
.filter_map(|entry| {
let tid = entry.file_name().to_string_lossy().into_owned();
let comm = std::fs::read_to_string(entry.path().join("comm")).ok()?;
Some(OsThread {
tid,
name: comm.trim_end().to_string(),
})
})
.collect();
threads.sort_by_key(|thread| thread.tid.parse::<u64>().unwrap_or(u64::MAX));
threads
}
#[cfg(not(target_os = "linux"))]
{
Vec::new()
}
}
fn render(
os: &[OsThread],
registered: &[crate::threads::ThreadRecord],
filter: Option<&str>,
) -> String {
let matches = |name: &str| filter.is_none_or(|needle| name.contains(needle));
let mut out = String::new();
out.push_str("From the operating system:\n");
if os.is_empty() {
out.push_str(&format!(
" (unavailable on {}: no /proc to enumerate)\n",
std::env::consts::OS
));
} else {
let shown: Vec<&OsThread> = os.iter().filter(|thread| matches(&thread.name)).collect();
if shown.is_empty() {
out.push_str(" (none matched the filter)\n");
}
for thread in shown {
out.push_str(&format!(" {:>8} {}\n", thread.tid, thread.name));
}
}
out.push_str("\nRegistered with exfiltrate:\n");
let shown: Vec<&crate::threads::ThreadRecord> = registered
.iter()
.filter(|record| matches(&record.name))
.collect();
if shown.is_empty() {
out.push_str(" (none; call exfiltrate::register_thread from a thread to add it)\n");
}
for record in shown {
match &record.note {
Some(note) => out.push_str(&format!(" {} {} — {note}\n", record.id, record.name)),
None => out.push_str(&format!(" {} {}\n", record.id, record.name)),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::threads::ThreadRecord;
fn os_sample() -> Vec<OsThread> {
vec![
OsThread {
tid: "100".to_string(),
name: "demo".to_string(),
},
OsThread {
tid: "101".to_string(),
name: "exfiltrate::listen".to_string(),
},
]
}
fn registered_sample() -> Vec<ThreadRecord> {
vec![ThreadRecord {
name: "render".to_string(),
id: "ThreadId(4)".to_string(),
note: Some("draws frames".to_string()),
}]
}
#[test]
fn both_sources_are_shown_and_labelled() {
let text = render(&os_sample(), ®istered_sample(), None);
assert!(text.contains("From the operating system:"), "{text}");
assert!(text.contains("exfiltrate::listen"), "{text}");
assert!(text.contains("Registered with exfiltrate:"), "{text}");
assert!(text.contains("draws frames"), "{text}");
}
#[test]
fn the_filter_applies_to_both_sources() {
let text = render(&os_sample(), ®istered_sample(), Some("render"));
assert!(!text.contains("exfiltrate::listen"), "{text}");
assert!(text.contains("render"), "{text}");
assert!(text.contains("(none matched the filter)"), "{text}");
}
#[test]
fn a_platform_without_proc_says_so_rather_than_showing_an_empty_list() {
let text = render(&[], ®istered_sample(), None);
assert!(text.contains("unavailable on"), "{text}");
assert!(text.contains("no /proc"), "{text}");
}
#[test]
fn an_empty_registry_explains_how_to_fill_it() {
let text = render(&os_sample(), &[], None);
assert!(text.contains("exfiltrate::register_thread"), "{text}");
}
#[test]
fn the_command_finds_this_process_on_linux() {
let text = Threads.execute(Vec::new()).unwrap().into_string();
#[cfg(target_os = "linux")]
assert!(!text.contains("unavailable on"), "{text}");
assert!(text.contains("Registered with exfiltrate:"), "{text}");
}
}