exfiltrate 0.4.0

An embeddable debug tool for Rust.
Documentation
// SPDX-License-Identifier: MIT OR Apache-2.0
//! Implements the native `threads` command.
#![cfg(not(target_arch = "wasm32"))]
use crate::command::{Command, Response};
use exfiltrate_internal::args::{ArgKind, ArgSpec, ParsedArgs};

/// The `threads` command.
///
/// See [`crate::threads`] for why this needs two sources and why neither alone
/// is sufficient.
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())
    }
}

/// A thread as the operating system reports it.
#[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();
        // Numeric order, not the lexicographic order readdir happens to give.
        threads.sort_by_key(|thread| thread.tid.parse::<u64>().unwrap_or(u64::MAX));
        threads
    }
    #[cfg(not(target_os = "linux"))]
    {
        Vec::new()
    }
}

/// Renders the merged listing.
///
/// Kept free of I/O so the layout — including the "no OS source" wording, which
/// is what a macOS user will actually see — is testable everywhere.
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(), &registered_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(), &registered_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(&[], &registered_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}");
    }
}