crates/signaltty-server/src/procscan.rs

//! Live `/proc` refresh (docs/07 layer 4): the pane's spawn argv is only
//! the first word. Every 10s the tick below re-reads the deepest live
//! descendant (shell-transparent) and the child's cwd, promoting the
//! agent kind and following directory changes. Emits `pane.updated`
//! per changed pane — quiet when nothing changed, so no event spam and
//! no persistence churn. Linux-only; elsewhere the tick is a no-op.

use std::collections::HashMap;

use chrono::Utc;

use signaltty_agent::{AgentAdapter, OverlayAdapter, ProcessInfo};
use signaltty_core::model::{AgentKind, LiveState};

use crate::store::{SharedStore, StoredEvent};

/// Basenames treated as wrappers: look past them to the real agent.
const SHELLS: &[&str] = &["sh", "bash", "zsh", "fish", "dash"];

fn read_stat(pid: u32) -> Option<(u32, String)> {
    let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
    // comm is `(name)` and may contain spaces/parens: split after the last ')'.
    let after = stat.rsplit(')').next()?;
    let mut fields = after.split_whitespace();
    let _state = fields.next()?;
    let ppid: u32 = fields.next()?.parse().ok()?;
    let comm = stat.split('(').nth(1)?.rsplit(')').next()?.to_string();
    Some((ppid, comm))
}

fn children_of(ppid: u32) -> Vec<u32> {
    let mut out = Vec::new();
    let Ok(dir) = std::fs::read_dir("/proc") else {
        return out;
    };
    for entry in dir.flatten() {
        let name = entry.file_name();
        let Some(pid): Option<u32> = name.to_str().and_then(|s| s.parse().ok()) else {
            continue;
        };
        if let Some((parent, _)) = read_stat(pid) {
            if parent == ppid {
                out.push(pid);
            }
        }
    }
    out.sort_unstable();
    out
}

/// Youngest deepest descendant of `pid`: follow the newest child while
/// the current basename is a shell, then stop. One shell-transparent
/// level keeps this cheap and honest (not a process-tree oracle).
pub fn deepest_descendant(pid: u32) -> u32 {
    let mut current = pid;
    for _ in 0..8 {
        let argv0 = proc_argv0(current).unwrap_or_default();
        if !SHELLS.contains(&argv0.as_str()) {
            break;
        }
        match children_of(current).into_iter().max() {
            Some(child) => current = child,
            None => break,
        }
    }
    current
}

/// Basename of `/proc/<pid>/cmdline`'s first field.
pub fn proc_argv0(pid: u32) -> Option<String> {
    let raw = std::fs::read(format!("/proc/{pid}/cmdline")).ok()?;
    let first = raw.split(|b| *b == 0).next()?.to_vec();
    if first.is_empty() {
        return None;
    }
    let text = String::from_utf8_lossy(&first).to_string();
    Some(text.rsplit('/').next().unwrap_or(&text).to_string())
}

/// Target of `/proc/<pid>/cwd` (None when unreadable or gone).
pub fn proc_cwd(pid: u32) -> Option<String> {
    std::fs::read_link(format!("/proc/{pid}/cwd"))
        .ok()
        .map(|p| p.to_string_lossy().to_string())
}

/// One tick: promote kinds and follow cwds. Returns the events to
/// broadcast (empty when nothing changed).
pub fn scan(
    store: &SharedStore,
    child_pids: &HashMap<String, u32>,
    overlays: &[OverlayAdapter],
) -> Vec<StoredEvent> {
    // Snapshot the live set first so the /proc walk runs lock-free.
    let live: Vec<(String, AgentKind, String)> = {
        let s = store.read().unwrap();
        s.panes
            .values()
            .filter(|p| matches!(p.live, LiveState::Live))
            .map(|p| (p.id.clone(), p.agent.kind, p.cwd.clone()))
            .collect()
    };
    let mut changed: Vec<(String, Option<AgentKind>, Option<String>)> = Vec::new();
    for (pane_id, kind, cwd) in live {
        let Some(child) = child_pids.get(&pane_id).copied() else {
            continue;
        };
        let target = deepest_descendant(child);
        let bin = proc_argv0(target).unwrap_or_default();
        let new_kind = if matches!(kind, AgentKind::Generic | AgentKind::None) && !bin.is_empty() {
            let proc = ProcessInfo {
                argv: vec![bin],
                cwd: String::new(),
                pid: Some(target),
            };
            let mut promoted = None;
            for overlay in overlays {
                if matches!(overlay.kind(), AgentKind::Generic | AgentKind::None) {
                    continue;
                }
                if overlay.identify(&proc) {
                    promoted = Some(overlay.kind());
                    break;
                }
            }
            promoted.or_else(|| {
                let specific = signaltty_agent::detect_kind(&proc.argv);
                if matches!(specific, AgentKind::Generic) {
                    None
                } else {
                    Some(specific)
                }
            })
        } else {
            None
        };
        let new_cwd = proc_cwd(target).filter(|c| c != &cwd);
        if new_kind.is_some() || new_cwd.is_some() {
            changed.push((pane_id, new_kind, new_cwd));
        }
    }
    if changed.is_empty() {
        return Vec::new();
    }
    let mut s = store.write().unwrap();
    let mut outbound = Vec::new();
    for (pane_id, kind, cwd) in changed {
        let Some(pane) = s.panes.get_mut(&pane_id) else {
            continue;
        };
        // The child may have exited between the walk and the lock.
        if !matches!(pane.live, LiveState::Live) {
            continue;
        }
        if let Some(kind) = kind {
            // Promotion only: never demote a hooked/known agent.
            if matches!(pane.agent.kind, AgentKind::Generic | AgentKind::None) {
                pane.agent.kind = kind;
            }
        }
        if let Some(cwd) = cwd {
            if std::path::Path::new(&cwd).is_dir() {
                pane.cwd = cwd;
            }
        }
        pane.last_activity_at = Utc::now();
        outbound.push(s.emit(
            signaltty_proto::event::PANE_UPDATED,
            serde_json::json!({"pane_id": pane_id}),
        ));
    }
    outbound
}

#[cfg(test)]
mod tests {
    use super::*;

    fn self_pid() -> u32 {
        std::process::id()
    }

    #[test]
    fn own_process_reads_back() {
        let pid = self_pid();
        let argv0 = proc_argv0(pid).unwrap();
        assert!(!argv0.is_empty(), "test binary has argv[0]");
        let cwd = proc_cwd(pid).unwrap();
        assert!(std::path::Path::new(&cwd).is_dir());
        // read_stat agrees on parentage with std.
        let (ppid, _comm) = read_stat(pid).unwrap();
        assert!(ppid > 0);
    }

    #[test]
    fn unknown_pids_read_none() {
        assert_eq!(proc_argv0(1 << 30), None);
        assert_eq!(proc_cwd(1 << 30), None);
        assert_eq!(read_stat(1 << 30), None);
    }

    #[test]
    fn deepest_descendant_stops_at_non_shell() {
        // The test binary is not a shell: descent stops immediately.
        assert_eq!(deepest_descendant(self_pid()), self_pid());
    }
}