diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 0c9971fb..a5b78bbe 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -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>, + snapshot: Arc, } #[derive(Debug)] @@ -273,6 +273,31 @@ struct WindowsProcessEntry { cmdline: Option, } +#[derive(Debug)] +struct ProcessSnapshot { + entries: Vec, + agent_indices: OnceLock>, +} + +impl ProcessSnapshot { + fn new(entries: Vec) -> 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 { - 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 { @@ -647,8 +672,11 @@ fn available_pane_shell_from_snapshot( } pub fn foreground_group_leader_job(process_group_id: u32) -> Option { - 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 Option { - 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 { @@ -668,24 +696,25 @@ pub fn process_cwd(pid: u32) -> Option { .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 { - 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, ) -> Option { + 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 { + 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 = entries .iter() .map(|entry| (entry.pid, entry.parent_pid)) @@ -848,7 +886,7 @@ fn snapshot_processes() -> Vec { output } -fn cached_foreground_processes() -> Arc> { +fn cached_foreground_processes() -> Arc { 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, - ) -> Arc> { + ) -> Arc { 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]