diff --git a/Cargo.lock b/Cargo.lock index a8a9bb1c79e..ad760e911e2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7461,6 +7461,7 @@ dependencies = [ "settings", "smallvec", "strum 0.27.2", + "sysinfo 0.37.2", "task", "telemetry", "theme", diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 19242ee3f05..348f41371e6 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -57,6 +57,7 @@ serde_json.workspace = true settings.workspace = true smallvec.workspace = true strum.workspace = true +sysinfo.workspace = true telemetry.workspace = true theme.workspace = true theme_settings.workspace = true diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index 04246fcd645..1eee2d818b0 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -3955,7 +3955,7 @@ impl GitPanel { }; let repo_path = repo.read(cx).work_directory_abs_path.display().to_string(); - let text = repo.read(cx).job_debug_queue().to_debug_string(); + let queue_value = repo.read(cx).job_debug_queue().to_debug_value(); let title = format!("Git Job Queue: {repo_path}"); let json_language = self.project.read(cx).languages().language_for_name("JSON"); @@ -3966,6 +3966,27 @@ impl GitPanel { .spawn(cx, async move |cx| { let json_language = json_language.await.ok(); + // Best-effort: gather runtime diagnostics off the main thread. + // Any failure inside `gather` is logged and produces an empty + // section; this `.await` itself cannot meaningfully fail and + // must never prevent us from showing the queue dump. + let diagnostics = cx + .background_spawn(crate::git_runtime_diagnostics::gather()) + .await; + + let mut combined = queue_value; + if let serde_json::Value::Object(ref mut map) = combined + && let serde_json::Value::Object(diag_map) = diagnostics + && !diag_map.is_empty() + { + map.insert( + "runtime_diagnostics".into(), + serde_json::Value::Object(diag_map), + ); + } + + let text = serde_json::to_string_pretty(&combined).unwrap_or_default(); + let buffer = project .update(cx, |project, cx| { project.create_buffer(json_language, false, cx) diff --git a/crates/git_ui/src/git_runtime_diagnostics.rs b/crates/git_ui/src/git_runtime_diagnostics.rs new file mode 100644 index 00000000000..8377848fb22 --- /dev/null +++ b/crates/git_ui/src/git_runtime_diagnostics.rs @@ -0,0 +1,415 @@ +//! Best-effort runtime diagnostics gathered to accompany the git job queue +//! debug dump. Every individual step is fallible and any failure is logged at +//! `warn` level and silently omitted from the output. The dump itself must +//! never fail just because diagnostics couldn't be collected. +//! +//! What we gather: +//! - All transitive descendant processes of the current Zed process +//! (cross-platform via `sysinfo`). +//! - On Linux: each descendant's `/proc//wchan` (kernel function the +//! thread is currently sleeping in) and `State:` from `/proc//status`. +//! - On macOS: for any descendant whose executable name contains `git`, a +//! short `sample`-based user-space stack and `lsof` output. Both require +//! the corresponding system binaries; if they aren't present or the +//! invocation fails we skip them. +//! - On Windows: just the process tree (no portable way to grab another +//! process's stack). +//! +//! The output is a `serde_json::Value`. Callers merge it into whatever larger +//! JSON payload they're producing. +//! +//! This is invoked from a developer-only "show git job queue" action, so it +//! is acceptable for the macOS path to spend a few seconds sampling. + +use serde_json::{Map, Value}; + +pub async fn gather() -> Value { + let mut diag = Map::new(); + + match collect_process_tree() { + Ok(tree) => { + diag.insert("processes".into(), tree); + } + Err(err) => { + log::warn!("git runtime diagnostics: failed to collect process tree: {err:#}"); + } + } + + #[cfg(target_os = "linux")] + { + match collect_linux_proc_info() { + Ok(info) => { + diag.insert("linux_proc".into(), info); + } + Err(err) => { + log::warn!("git runtime diagnostics: failed to read /proc info: {err:#}"); + } + } + } + + #[cfg(target_os = "macos")] + { + match collect_macos_git_child_diagnostics().await { + Ok(info) => { + if !info.is_null() { + diag.insert("macos_git_children".into(), info); + } + } + Err(err) => { + log::warn!( + "git runtime diagnostics: failed to collect macOS git-child info: {err:#}" + ); + } + } + } + + Value::Object(diag) +} + +/// Walk the descendant tree of the current process and return a JSON array +/// describing each descendant. Cross-platform; uses `sysinfo`. +fn collect_process_tree() -> anyhow::Result { + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System, UpdateKind}; + + let current_pid = sysinfo::get_current_pid() + .map_err(|e| anyhow::anyhow!("sysinfo::get_current_pid failed: {e}"))?; + + let refresh = ProcessRefreshKind::nothing() + .with_cmd(UpdateKind::Always) + .with_exe(UpdateKind::Always); + let mut system = System::new_with_specifics(RefreshKind::nothing().with_processes(refresh)); + system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh); + + let descendants = descendants_of(&system, current_pid); + + let entries: Vec = descendants + .iter() + .filter_map(|pid| { + let process = system.process(*pid)?; + let cmd = sanitize_cmd( + process + .cmd() + .iter() + .map(|s| s.to_string_lossy().into_owned()), + ); + Some(serde_json::json!({ + "pid": pid.as_u32(), + "ppid": process.parent().map(|p| p.as_u32()), + "name": process.name().to_string_lossy(), + "exe": process.exe().map(|p| p.display().to_string()), + "cmd": cmd, + "status": format!("{:?}", process.status()), + "run_time_secs": process.run_time(), + })) + }) + .collect(); + + Ok(serde_json::json!({ + "zed_pid": current_pid.as_u32(), + "descendant_count": entries.len(), + "descendants": entries, + })) +} + +/// Scrub a process's reported argv to avoid leaking environment-variable +/// values. sysinfo's `Process::cmd()` on macOS goes through `KERN_PROCARGS2` +/// and can include envp in addition to argv for some processes, which means +/// the raw output can contain things like `ANTHROPIC_API_KEY=…`. We replace +/// any entry that matches a conservative env-var pattern (uppercase +/// identifier ending in `=`) with `KEY=`. If *every* entry got +/// redacted then sysinfo's data for this process is too garbled to trust as +/// argv, so we return `None` so the caller emits a JSON null rather than +/// something misleading. +fn sanitize_cmd(cmd: impl IntoIterator) -> Option> { + let sanitized: Vec = cmd.into_iter().map(redact_env_var_entry).collect(); + if sanitized.is_empty() { + return None; + } + let all_redacted = sanitized.iter().all(|s| s.ends_with("=")); + if all_redacted { None } else { Some(sanitized) } +} + +fn redact_env_var_entry(entry: String) -> String { + // Match `IDENT=...` where IDENT is at least two characters starting with + // an uppercase letter or underscore and otherwise uppercase/digit/under. + // CLI flags (`--foo=bar`, `-x=y`, `/path=value`) don't match. + let Some(eq_index) = entry.find('=') else { + return entry; + }; + let key = &entry[..eq_index]; + if !key.is_empty() + && key + .chars() + .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_') + && key.starts_with(|c: char| c.is_ascii_uppercase() || c == '_') + { + format!("{key}=") + } else { + entry + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[track_caller] + fn assert_redacts(input: &str, expected: &str) { + assert_eq!(redact_env_var_entry(input.to_string()), expected); + } + + #[test] + fn redacts_secret_looking_env_vars() { + assert_redacts( + "ANTHROPIC_API_KEY=sk-ant-api03-abcdef", + "ANTHROPIC_API_KEY=", + ); + assert_redacts( + "AWS_SECRET_ACCESS_KEY=anything-at-all", + "AWS_SECRET_ACCESS_KEY=", + ); + assert_redacts("PATH=/usr/bin:/bin", "PATH="); + assert_redacts("A=1", "A="); + assert_redacts("_FOO=bar", "_FOO="); + // Values may legitimately contain `=`; only the value portion is dropped. + assert_redacts("TOKEN=abc=def=ghi", "TOKEN="); + // Empty value still redacts (and importantly, doesn't pretend to be a flag). + assert_redacts("PASSWORD=", "PASSWORD="); + } + + #[test] + fn leaves_real_argv_alone() { + // CLI flags that happen to contain `=`. + assert_redacts("--max-old-space-size=8092", "--max-old-space-size=8092"); + assert_redacts("-Dfoo=bar", "-Dfoo=bar"); + // Paths. + assert_redacts("/opt/homebrew/bin/node", "/opt/homebrew/bin/node"); + // Bare strings without `=`. + assert_redacts("--cancellationPipeName", "--cancellationPipeName"); + assert_redacts( + "npm exec mcp-remote https://example.com", + "npm exec mcp-remote https://example.com", + ); + // Lowercase / mixed-case identifiers aren't env vars by convention; leave them. + assert_redacts("foo=bar", "foo=bar"); + assert_redacts("camelCase=value", "camelCase=value"); + // Pathological: `=value` with no key. + assert_redacts("=value", "=value"); + } + + #[test] + fn sanitize_returns_none_when_everything_redacted() { + let cmd = vec![ + "FOO=1".to_string(), + "BAR=2".to_string(), + "ANTHROPIC_API_KEY=secret".to_string(), + ]; + assert_eq!(sanitize_cmd(cmd), None); + } + + #[test] + fn sanitize_preserves_real_argv_and_redacts_env_vars() { + // The exact pattern observed in a real diagnostic dump. + let cmd = vec![ + "npm exec mcp-remote https://mcp.linear.app/mcp".to_string(), + "ALACRITTY_WINDOW_ID=38654706047".to_string(), + "AMP_FORCE_BEL=1".to_string(), + "ANTHROPIC_API_KEY=sk-ant-api03-realsecret".to_string(), + ]; + assert_eq!( + sanitize_cmd(cmd), + Some(vec![ + "npm exec mcp-remote https://mcp.linear.app/mcp".to_string(), + "ALACRITTY_WINDOW_ID=".to_string(), + "AMP_FORCE_BEL=".to_string(), + "ANTHROPIC_API_KEY=".to_string(), + ]) + ); + } + + #[test] + fn sanitize_handles_empty_cmd() { + let cmd: Vec = Vec::new(); + assert_eq!(sanitize_cmd(cmd), None); + } +} + +fn descendants_of(system: &sysinfo::System, root: sysinfo::Pid) -> Vec { + let mut parent_map: std::collections::HashMap> = + std::collections::HashMap::new(); + for (pid, process) in system.processes() { + if let Some(parent) = process.parent() { + parent_map.entry(parent).or_default().push(*pid); + } + } + let mut out = Vec::new(); + let mut stack = vec![root]; + while let Some(p) = stack.pop() { + if let Some(children) = parent_map.get(&p) { + for c in children { + out.push(*c); + stack.push(*c); + } + } + } + out +} + +#[cfg(target_os = "linux")] +fn collect_linux_proc_info() -> anyhow::Result { + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System}; + + let current_pid = sysinfo::get_current_pid() + .map_err(|e| anyhow::anyhow!("sysinfo::get_current_pid failed: {e}"))?; + let refresh = ProcessRefreshKind::nothing(); + let mut system = System::new_with_specifics(RefreshKind::nothing().with_processes(refresh)); + system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh); + + let descendants = descendants_of(&system, current_pid); + + let mut entries = Vec::new(); + for pid in descendants { + let pid_u32 = pid.as_u32(); + + let wchan = match std::fs::read_to_string(format!("/proc/{pid_u32}/wchan")) { + Ok(s) => Value::String(s.trim().to_string()), + Err(err) => { + log::warn!("git runtime diagnostics: failed to read /proc/{pid_u32}/wchan: {err}"); + Value::Null + } + }; + + let state = match std::fs::read_to_string(format!("/proc/{pid_u32}/status")) { + Ok(contents) => contents + .lines() + .find(|l| l.starts_with("State:")) + .map(|l| Value::String(l.trim_start_matches("State:").trim().to_string())) + .unwrap_or(Value::Null), + Err(err) => { + log::warn!("git runtime diagnostics: failed to read /proc/{pid_u32}/status: {err}"); + Value::Null + } + }; + + entries.push(serde_json::json!({ + "pid": pid_u32, + "wchan": wchan, + "state": state, + })); + } + + Ok(serde_json::json!({ "descendants": entries })) +} + +#[cfg(target_os = "macos")] +async fn collect_macos_git_child_diagnostics() -> anyhow::Result { + use std::path::Path; + use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, RefreshKind, System, UpdateKind}; + + let sample_available = Path::new("/usr/bin/sample").exists(); + let lsof_available = + Path::new("/usr/sbin/lsof").exists() || Path::new("/usr/bin/lsof").exists(); + let lsof_path = if Path::new("/usr/sbin/lsof").exists() { + "/usr/sbin/lsof" + } else { + "/usr/bin/lsof" + }; + + if !sample_available && !lsof_available { + return Ok(Value::Null); + } + + let current_pid = sysinfo::get_current_pid() + .map_err(|e| anyhow::anyhow!("sysinfo::get_current_pid failed: {e}"))?; + + let refresh = ProcessRefreshKind::nothing().with_exe(UpdateKind::Always); + let mut system = System::new_with_specifics(RefreshKind::nothing().with_processes(refresh)); + system.refresh_processes_specifics(ProcessesToUpdate::All, true, refresh); + + let descendants = descendants_of(&system, current_pid); + + // Limit ourselves to git-flavored descendants. We don't want to spend + // several seconds sampling unrelated children (terminals, language + // servers, etc.). Match by name containing "git" — covers `git`, + // `git-remote-https`, `git-credential-osxkeychain`, hook subprocesses + // named with `git` in them, etc. + let git_descendants: Vec = descendants + .iter() + .filter_map(|pid| { + let process = system.process(*pid)?; + let name = process.name().to_string_lossy(); + if name.contains("git") { + Some(pid.as_u32()) + } else { + None + } + }) + .collect(); + + if git_descendants.is_empty() { + return Ok(Value::Null); + } + + let mut entries = Vec::new(); + for pid in git_descendants { + let mut entry = Map::new(); + entry.insert("pid".into(), Value::from(pid)); + + if sample_available { + match run_capturing("/usr/bin/sample", &[&pid.to_string(), "2", "-mayDie"]).await { + Ok(output) => { + entry.insert("sample".into(), Value::String(truncate(output, 64 * 1024))); + } + Err(err) => { + log::warn!("git runtime diagnostics: `sample {pid}` failed: {err}"); + } + } + } + + if lsof_available { + match run_capturing(lsof_path, &["-p", &pid.to_string()]).await { + Ok(output) => { + entry.insert("lsof".into(), Value::String(truncate(output, 64 * 1024))); + } + Err(err) => { + log::warn!("git runtime diagnostics: `lsof -p {pid}` failed: {err}"); + } + } + } + + entries.push(Value::Object(entry)); + } + + Ok(Value::Array(entries)) +} + +#[cfg(target_os = "macos")] +async fn run_capturing(program: &str, args: &[&str]) -> anyhow::Result { + let output = util::command::new_command(program) + .args(args) + .output() + .await?; + if !output.status.success() { + anyhow::bail!( + "{program} exited with status {:?}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + } + Ok(String::from_utf8_lossy(&output.stdout).into_owned()) +} + +#[cfg(target_os = "macos")] +fn truncate(mut s: String, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s; + } + // Find a UTF-8 char boundary at or before `max_bytes`. + let mut cut = max_bytes; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + s.push_str("\n…(truncated)"); + s +} diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 4fda322cc89..f791ade8e0c 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -39,6 +39,7 @@ pub mod file_diff_view; pub mod git_panel; mod git_panel_settings; pub mod git_picker; +mod git_runtime_diagnostics; pub mod multi_diff_view; pub mod picker_prompt; pub mod project_diff; diff --git a/crates/project/src/git_store/job_debug_queue.rs b/crates/project/src/git_store/job_debug_queue.rs index c204451d58b..6a31e62b02c 100644 --- a/crates/project/src/git_store/job_debug_queue.rs +++ b/crates/project/src/git_store/job_debug_queue.rs @@ -113,6 +113,10 @@ impl GitJobDebugQueue { } pub fn to_debug_string(&self) -> String { + serde_json::to_string_pretty(&self.to_debug_value()).unwrap_or_default() + } + + pub fn to_debug_value(&self) -> serde_json::Value { let mut entries = Vec::new(); let mut pending_count = 0u64; @@ -141,7 +145,7 @@ impl GitJobDebugQueue { let json_entries: Vec = entries.into_iter().map(|(_, json)| json).collect(); - let json = serde_json::json!({ + serde_json::json!({ "summary": { "pending": pending_count, "running": running_count, @@ -149,9 +153,7 @@ impl GitJobDebugQueue { "skipped": skipped_count, }, "entries": json_entries, - }); - - serde_json::to_string_pretty(&json).unwrap_or_default() + }) } fn format_pending(&self, job: &PendingJob) -> serde_json::Value {