fix(windows): share Git Bash agent candidate scans (#2494)

refs #2459
This commit is contained in:
JJ Liebig 2026-08-07 21:22:04 +02:00 committed by GitHub
parent bdb5222eec
commit 7d77e927eb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
1 changed files with 111 additions and 49 deletions

View File

@ -7,7 +7,7 @@ use std::{
ptr::{copy_nonoverlapping, null_mut},
sync::{
atomic::{AtomicU64, Ordering as AtomicOrdering},
Arc, LazyLock, Mutex,
Arc, LazyLock, Mutex, OnceLock,
},
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
@ -233,7 +233,7 @@ pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) ->
#[derive(Debug)]
struct CachedProcessSnapshot {
built_at: Instant,
entries: Arc<Vec<WindowsProcessEntry>>,
snapshot: Arc<ProcessSnapshot>,
}
#[derive(Debug)]
@ -273,6 +273,31 @@ struct WindowsProcessEntry {
cmdline: Option<String>,
}
#[derive(Debug)]
struct ProcessSnapshot {
entries: Vec<WindowsProcessEntry>,
agent_indices: OnceLock<Vec<usize>>,
}
impl ProcessSnapshot {
fn new(entries: Vec<WindowsProcessEntry>) -> Self {
Self {
entries,
agent_indices: OnceLock::new(),
}
}
fn agent_indices(&self) -> &[usize] {
self.agent_indices.get_or_init(|| {
self.entries
.iter()
.enumerate()
.filter_map(|(index, entry)| process_entry_identifies_agent(entry).then_some(index))
.collect()
})
}
}
pub fn raise_server_nofile_limit() {}
pub(crate) fn apply_pane_runtime_marker_platform(command: &mut portable_pty::CommandBuilder) {
@ -625,8 +650,8 @@ pub fn current_process_is_detached_server_daemon() -> bool {
}
pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> {
let entries = snapshot_processes();
select_pane_foreground_job(child_pid, &entries)
let snapshot = ProcessSnapshot::new(snapshot_processes());
select_pane_foreground_job_from_snapshot(child_pid, &snapshot)
}
pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> {
@ -647,8 +672,11 @@ fn available_pane_shell_from_snapshot(
}
pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> {
let entries = cached_foreground_processes();
let entry = entries.iter().find(|entry| entry.pid == process_group_id)?;
let snapshot = cached_foreground_processes();
let entry = snapshot
.entries
.iter()
.find(|entry| entry.pid == process_group_id)?;
Some(ForegroundJob {
process_group_id,
processes: vec![foreground_process_from_entry(entry)],
@ -656,8 +684,8 @@ pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJo
}
pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
let entries = cached_foreground_processes();
select_pane_foreground_job(child_pid, &entries).map(|job| job.process_group_id)
let snapshot = cached_foreground_processes();
select_pane_foreground_job_from_snapshot(child_pid, &snapshot).map(|job| job.process_group_id)
}
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
@ -668,24 +696,25 @@ pub fn process_cwd(pid: u32) -> Option<PathBuf> {
.filter(|path| path.is_absolute())
}
fn select_pane_foreground_job(
fn select_pane_foreground_job_from_snapshot(
shell_pid: u32,
entries: &[WindowsProcessEntry],
snapshot: &ProcessSnapshot,
) -> Option<ForegroundJob> {
select_pane_foreground_job_with_runtime_inspection(
select_pane_foreground_job_from_snapshot_with_runtime_inspection(
shell_pid,
entries,
snapshot,
|shell| process_is_git_bash(shell.pid),
|entry| process_runtime_marker(entry.pid),
)
}
fn select_pane_foreground_job_with_runtime_inspection(
fn select_pane_foreground_job_from_snapshot_with_runtime_inspection(
shell_pid: u32,
entries: &[WindowsProcessEntry],
snapshot: &ProcessSnapshot,
shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool,
mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option<String>,
) -> Option<ForegroundJob> {
let entries = &snapshot.entries;
let shell = entries.iter().find(|entry| entry.pid == shell_pid)?;
let descendants = descendant_entries(shell_pid, entries);
let mut candidates = Vec::new();
@ -702,11 +731,8 @@ fn select_pane_foreground_job_with_runtime_inspection(
return Some(foreground_job_from_entry(shell));
}
let escaped_candidates: Vec<_> = entries
.iter()
.filter(|entry| process_entry_identifies_agent(entry))
.collect();
if escaped_candidates.is_empty() {
let escaped_agent_indices = snapshot.agent_indices();
if escaped_agent_indices.is_empty() {
return Some(foreground_job_from_entry(shell));
}
@ -714,8 +740,9 @@ fn select_pane_foreground_job_with_runtime_inspection(
else {
return Some(foreground_job_from_entry(shell));
};
let matching_candidates: Vec<_> = escaped_candidates
.into_iter()
let matching_candidates: Vec<_> = escaped_agent_indices
.iter()
.map(|&index| &entries[index])
.filter(|entry| runtime_marker(entry).as_deref() == Some(shell_runtime_marker.as_str()))
.collect();
let selected =
@ -723,6 +750,14 @@ fn select_pane_foreground_job_with_runtime_inspection(
Some(foreground_job_from_entry(selected))
}
#[cfg(test)]
fn select_pane_foreground_job(
shell_pid: u32,
entries: &[WindowsProcessEntry],
) -> Option<ForegroundJob> {
select_pane_foreground_job_from_snapshot(shell_pid, &ProcessSnapshot::new(entries.to_vec()))
}
fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool {
crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some()
}
@ -738,6 +773,9 @@ fn select_topmost_agent_chain_candidate<'a>(
candidates: &[&'a WindowsProcessEntry],
entries: &[WindowsProcessEntry],
) -> Option<&'a WindowsProcessEntry> {
if candidates.is_empty() {
return None;
}
let parent_by_pid: HashMap<u32, u32> = entries
.iter()
.map(|entry| (entry.pid, entry.parent_pid))
@ -848,7 +886,7 @@ fn snapshot_processes() -> Vec<WindowsProcessEntry> {
output
}
fn cached_foreground_processes() -> Arc<Vec<WindowsProcessEntry>> {
fn cached_foreground_processes() -> Arc<ProcessSnapshot> {
let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE
.lock()
.unwrap_or_else(|err| err.into_inner());
@ -860,19 +898,19 @@ impl ProcessSnapshotCache {
&mut self,
max_age: Duration,
build: impl FnOnce() -> Vec<WindowsProcessEntry>,
) -> Arc<Vec<WindowsProcessEntry>> {
) -> Arc<ProcessSnapshot> {
if let Some(cached) = &self.cached {
if cached.built_at.elapsed() < max_age {
return Arc::clone(&cached.entries);
return Arc::clone(&cached.snapshot);
}
}
let entries = Arc::new(build());
let snapshot = Arc::new(ProcessSnapshot::new(build()));
self.cached = Some(CachedProcessSnapshot {
built_at: Instant::now(),
entries: Arc::clone(&entries),
snapshot: Arc::clone(&snapshot),
});
entries
snapshot
}
}
@ -2479,10 +2517,11 @@ mod tests {
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
test_entry(20, 10, "codex.exe", &["codex.exe"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| panic!("Git Bash fallback must not run after normal detection succeeds"),
|_| panic!("runtime marker must not be read after normal detection succeeds"),
)
@ -2494,8 +2533,8 @@ mod tests {
}
#[test]
fn windows_process_tree_recovers_git_bash_exec_chain_from_runtime_marker() {
let entries = vec![
fn windows_process_tree_shares_snapshot_candidates_across_git_bash_panes() {
let snapshot = super::ProcessSnapshot::new(vec![
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
test_entry(
11,
@ -2503,6 +2542,7 @@ mod tests {
"bash.exe",
&[r"C:\Program Files\Git\usr\bin\bash.exe"],
),
test_entry(12, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
test_entry(
20,
99,
@ -2524,23 +2564,40 @@ mod tests {
"codex.exe",
&[r"C:\npm\node_modules\@openai\codex\bin\codex.exe"],
),
];
test_entry(50, 98, "claude.exe", &["claude.exe"]),
]);
let mut inspected = Vec::new();
assert!(snapshot.agent_indices.get().is_none());
let marker = |entry: &super::WindowsProcessEntry| match entry.pid {
12 | 50 => Some("pane-b".to_string()),
_ => Some("pane-a".to_string()),
};
let job = super::select_pane_foreground_job_with_runtime_inspection(
let first = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| true,
|entry| {
inspected.push(entry.pid);
Some("pane-a".to_string())
marker(entry)
},
)
.unwrap();
let indices = snapshot.agent_indices.get().unwrap();
let second = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
12,
&snapshot,
|_| true,
marker,
)
.unwrap();
assert_eq!(job.process_group_id, 20);
assert_eq!(job.processes[0].name, "sh.exe");
assert_eq!(inspected, vec![10, 20, 30, 40]);
assert_eq!(first.process_group_id, 20);
assert_eq!(first.processes[0].name, "sh.exe");
assert_eq!(second.process_group_id, 50);
assert_eq!(indices, &[3, 4, 5, 6]);
assert!(std::ptr::eq(indices, snapshot.agent_indices.get().unwrap()));
assert_eq!(inspected, vec![10, 20, 30, 40, 50]);
}
#[test]
@ -2549,10 +2606,11 @@ mod tests {
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
test_entry(20, 99, "codex.exe", &["codex.exe"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| false,
|_| panic!("runtime marker must not be read for non-Git-Bash panes"),
)
@ -2567,10 +2625,11 @@ mod tests {
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
test_entry(20, 99, "git.exe", &["git.exe", "status"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| true,
|_| panic!("runtime marker must not be read without an agent candidate"),
)
@ -2585,11 +2644,12 @@ mod tests {
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
test_entry(20, 99, "codex.exe", &["codex.exe"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
for shell_marker in [None, Some(String::new())] {
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| true,
|entry| {
if entry.pid == 10 {
@ -2611,10 +2671,11 @@ mod tests {
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
test_entry(20, 99, "codex.exe", &["codex.exe"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| true,
|entry| Some(if entry.pid == 10 { "pane-a" } else { "pane-b" }.to_string()),
)
@ -2631,10 +2692,11 @@ mod tests {
test_entry(20, 99, "codex.exe", &["codex.exe"]),
test_entry(30, 98, "claude.exe", &["claude.exe"]),
];
let snapshot = super::ProcessSnapshot::new(entries);
let job = super::select_pane_foreground_job_with_runtime_inspection(
let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection(
10,
&entries,
&snapshot,
|_| true,
|_| Some("pane-a".to_string()),
)
@ -2669,7 +2731,7 @@ mod tests {
assert!(Arc::ptr_eq(&first, &second));
assert!(!Arc::ptr_eq(&second, &refreshed));
assert_eq!(builds, 2);
assert_eq!(refreshed[0].pid, 20);
assert_eq!(refreshed.entries[0].pid, 20);
}
#[test]