diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-08-11 18:08:51 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-11 18:08:51 +0300 |
| commit | 06ca0baa12f4203c5bbad9ecadf53f9a475a52b2 (patch) | |
| tree | c97a018bb3aecd4c0f93d58a008fa7606f9dc3f2 /src | |
| parent | 6b0e6165afcc1aff6bea0c620ca2a812b8a35e13 (diff) | |
fix(windows): reduce idle agent detection CPU (#2651)
* fix(windows): reduce idle agent detection CPU
refs #2642
* fix(windows): gate agent process discovery
refs #2642
---------
Co-authored-by: Jonathan Liebig <jonathan.liebig@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/pane.rs | 133 | ||||
| -rw-r--r-- | src/platform/windows.rs | 761 |
2 files changed, 809 insertions, 85 deletions
diff --git a/src/pane.rs b/src/pane.rs index c95da331..83921ab0 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -446,6 +446,23 @@ fn should_skip_process_probe_for_lifecycle_authority( && !foreground_group_changed(input.foreground_pgid, input.last_foreground_pgid) } +#[cfg(any(windows, test))] +fn should_observe_foreground_process_group( + lifecycle_authority: bool, + content_changed: bool, + elapsed: std::time::Duration, + input: ProcessProbeInput, +) -> bool { + !input.has_process_probe + || input.current_agent.is_none() + || input.suppressed_agent.is_some() + || input.pending_foreground_shell_clear + || input.pending_restore_probe + || content_changed + || (lifecycle_authority && elapsed >= PROCESS_RECHECK_IDENTIFIED) + || (!lifecycle_authority && input.elapsed_since_process_check >= PROCESS_RECHECK_IDENTIFIED) +} + fn should_probe_foreground_job(input: ProcessProbeInput) -> bool { if input.pending_foreground_shell_clear || input.pending_restore_probe { return true; @@ -2156,6 +2173,8 @@ impl PaneRuntime { let mut state = AgentState::Idle; let mut last_visible_idle = initial_state.detected_agent.is_some(); let mut last_process_check = Instant::now(); + #[cfg(windows)] + let mut last_observation = (Instant::now(), Some(0)); let mut last_foreground_pgid = None; let mut has_process_probe = false; let mut acquisition_started_at = None; @@ -2224,23 +2243,50 @@ impl PaneRuntime { let mut agent = agent_presence.current_agent(); let lifecycle_authority_active = full_lifecycle_authority_active_for_task.load(Ordering::Acquire); - let foreground_pgid = (pid > 0) - .then(|| detect::foreground_process_group_id(pid)) - .flatten(); + let process_probe_input = ProcessProbeInput { + current_agent: agent, + suppressed_agent, + foreground_pgid: last_foreground_pgid, + last_foreground_pgid, + has_process_probe, + acquisition_age: acquisition_started_at + .map(|started| now.duration_since(started)), + pending_foreground_shell_clear, + pending_restore_probe, + elapsed_since_process_check: now.duration_since(last_process_check), + }; + #[cfg(windows)] + let content_seq = detection_content_seq.load(Ordering::Relaxed); + #[cfg(windows)] + let last_content_seq = last_observation.1; + #[cfg(windows)] + let foreground_observation_due = should_observe_foreground_process_group( + lifecycle_authority_active, + last_content_seq != Some(content_seq) + && (last_content_seq.is_some() + || now.duration_since(last_observation.0) >= TICK_IDENTIFIED), + now.duration_since(last_observation.0), + process_probe_input, + ); + #[cfg(not(windows))] + let foreground_observation_due = true; + let foreground_pgid = match (pid, foreground_observation_due) { + (0, _) => None, + (_, true) => detect::foreground_process_group_id(pid), + _ => last_foreground_pgid, + }; + #[cfg(windows)] + if pid > 0 && foreground_observation_due { + let retry = + last_content_seq.is_some() && last_content_seq != Some(content_seq); + last_observation = (now, (!retry).then_some(content_seq)); + } let process_group_changed = foreground_group_changed(foreground_pgid, last_foreground_pgid); let should_check_process = pid > 0 && { let process_probe_input = ProcessProbeInput { - current_agent: agent, - suppressed_agent, foreground_pgid, - last_foreground_pgid, - has_process_probe, - acquisition_age: acquisition_started_at - .map(|started| now.duration_since(started)), - pending_foreground_shell_clear, - pending_restore_probe, - elapsed_since_process_check: now.duration_since(last_process_check), + ..process_probe_input }; !should_skip_process_probe_for_lifecycle_authority( lifecycle_authority_active, @@ -3796,6 +3842,69 @@ mod tests { } #[test] + fn windows_foreground_observation_schedule_preserves_lifecycle_checks() { + let quiet = ProcessProbeInput { + current_agent: Some(Agent::Codex), + ..process_probe_input() + }; + let before_safety_bound = PROCESS_RECHECK_IDENTIFIED - std::time::Duration::from_millis(1); + let content_retry = std::time::Duration::from_millis(300); + let observe = |lifecycle, content_changed, elapsed, input| { + should_observe_foreground_process_group(lifecycle, content_changed, elapsed, input) + }; + let content_due = |last: Option<u64>, current, elapsed| { + last != Some(current) && (last.is_some() || elapsed >= content_retry) + }; + + assert!(!observe(false, false, before_safety_bound, quiet)); + assert!(observe(false, true, before_safety_bound, quiet)); + assert!(observe(true, true, before_safety_bound, quiet)); + assert!(content_due(Some(0), 1, std::time::Duration::ZERO)); + assert!(!content_due( + None, + 1, + content_retry - std::time::Duration::from_millis(1) + )); + assert!(content_due(None, 1, content_retry)); + assert!(observe( + false, + false, + before_safety_bound, + ProcessProbeInput { + elapsed_since_process_check: PROCESS_RECHECK_IDENTIFIED, + ..quiet + } + )); + assert!(observe(true, false, PROCESS_RECHECK_IDENTIFIED, quiet)); + + for immediate in [ + ProcessProbeInput { + has_process_probe: false, + ..quiet + }, + ProcessProbeInput { + current_agent: None, + acquisition_age: Some(std::time::Duration::ZERO), + ..quiet + }, + ProcessProbeInput { + pending_restore_probe: true, + ..quiet + }, + ProcessProbeInput { + suppressed_agent: Some(Agent::Codex), + ..quiet + }, + ProcessProbeInput { + pending_foreground_shell_clear: true, + ..quiet + }, + ] { + assert!(observe(false, false, std::time::Duration::ZERO, immediate)); + } + } + + #[test] fn unchanged_unidentified_foreground_group_skips_full_process_probe() { assert!(!should_probe_foreground_job(process_probe_input())); } diff --git a/src/platform/windows.rs b/src/platform/windows.rs index 05fdfe72..12ed784e 100644 --- a/src/platform/windows.rs +++ b/src/platform/windows.rs @@ -3,6 +3,7 @@ use std::{ collections::{HashMap, HashSet, VecDeque}, ffi::{c_void, OsStr}, mem::{size_of, MaybeUninit}, + os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle}, path::PathBuf, ptr::{copy_nonoverlapping, null_mut}, sync::{ @@ -81,6 +82,9 @@ use super::{ClipboardImage, ForegroundJob, Signal}; const STILL_ACTIVE: u32 = 259; const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250); +const FOREGROUND_SELECTION_RECHECK: Duration = Duration::from_secs(5); +const FOREGROUND_SELECTION_CACHE_CAPACITY: usize = 1_024; +const FOREGROUND_SELECTION_CACHE_RETENTION: Duration = Duration::from_secs(60); const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID"; pub(crate) fn terminal_title_for_presentation(title: &str) -> &str { @@ -250,6 +254,90 @@ struct ProcessSnapshotCache { cached: Option<CachedProcessSnapshot>, } +#[derive(Debug, Clone, PartialEq, Eq)] +struct ProcessSignature { + pid: u32, + parent_pid: u32, + name: String, +} + +impl ProcessSignature { + fn from_entry(entry: &WindowsProcessEntry) -> Self { + Self { + pid: entry.pid, + parent_pid: entry.parent_pid, + name: entry.name.clone(), + } + } + + fn matches(&self, entry: Option<&WindowsProcessEntry>) -> bool { + entry.is_some_and(|entry| { + self.pid == entry.pid && self.parent_pid == entry.parent_pid && self.name == entry.name + }) + } +} + +#[derive(Debug)] +enum ProcessIdentity { + Handle(OwnedHandle), + #[cfg(test)] + Stub { + running: bool, + creation_time: Option<u64>, + }, +} + +impl ProcessIdentity { + fn open(pid: u32) -> Option<Self> { + let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) }; + if handle.is_null() { + return None; + } + let handle = unsafe { OwnedHandle::from_raw_handle(handle.cast()) }; + Some(Self::Handle(handle)) + } + + fn running(&self) -> bool { + match self { + Self::Handle(handle) => { + let mut exit_code = 0; + let read = unsafe { + GetExitCodeProcess(handle.as_raw_handle().cast(), &mut exit_code) != 0 + }; + read && exit_code == STILL_ACTIVE + } + #[cfg(test)] + Self::Stub { running, .. } => *running, + } + } + + fn creation_time(&self) -> Option<u64> { + match self { + Self::Handle(handle) => process_creation_time(handle.as_raw_handle().cast()), + #[cfg(test)] + Self::Stub { creation_time, .. } => *creation_time, + } + } +} + +#[derive(Debug)] +struct CachedForegroundSelection { + shell: ProcessSignature, + selected: ProcessSignature, + descendants: Vec<ProcessSignature>, + descendant_identities: Vec<ProcessIdentity>, + shell_identity: ProcessIdentity, + selected_identity: ProcessIdentity, + job: ForegroundJob, + verified_at: Instant, + last_used: Instant, +} + +#[derive(Debug, Default)] +struct ForegroundSelectionCache { + entries: HashMap<u32, CachedForegroundSelection>, +} + #[derive(Debug)] struct CachedProcessRuntimeMarker { creation_time: u64, @@ -267,6 +355,8 @@ struct CachedGitBashProcess { static FOREGROUND_PROCESS_SNAPSHOT_CACHE: Mutex<ProcessSnapshotCache> = Mutex::new(ProcessSnapshotCache { cached: None }); +static FOREGROUND_SELECTION_CACHE: LazyLock<Mutex<ForegroundSelectionCache>> = + LazyLock::new(|| Mutex::new(ForegroundSelectionCache::default())); pub(crate) fn should_draw_host_cursor_by_default() -> bool { true @@ -305,29 +395,70 @@ pub(crate) fn local_datetime() -> Option<time::PrimitiveDateTime> { } #[derive(Debug, Clone, PartialEq, Eq)] +struct WindowsProcessCommand { + creation_time: Option<u64>, + argv0: Option<String>, + argv: Option<Vec<String>>, + cmdline: Option<String>, +} + +#[derive(Debug, Clone)] struct WindowsProcessEntry { pid: u32, parent_pid: u32, name: String, - argv0: Option<String>, - argv: Option<Vec<String>>, - cmdline: Option<String>, + command: OnceLock<WindowsProcessCommand>, +} + +impl WindowsProcessEntry { + fn command(&self) -> &WindowsProcessCommand { + self.command + .get_or_init(|| read_process_command(self.pid, &self.name)) + } } #[derive(Debug)] struct ProcessSnapshot { entries: Vec<WindowsProcessEntry>, + entry_by_pid: HashMap<u32, usize>, + children_by_parent: HashMap<u32, Vec<usize>>, agent_indices: OnceLock<Vec<usize>>, } impl ProcessSnapshot { fn new(entries: Vec<WindowsProcessEntry>) -> Self { + let mut entry_by_pid = HashMap::with_capacity(entries.len()); + let mut children_by_parent = HashMap::<u32, Vec<usize>>::new(); + for (index, entry) in entries.iter().enumerate() { + entry_by_pid.insert(entry.pid, index); + children_by_parent + .entry(entry.parent_pid) + .or_default() + .push(index); + } Self { entries, + entry_by_pid, + children_by_parent, agent_indices: OnceLock::new(), } } + fn entry(&self, pid: u32) -> Option<&WindowsProcessEntry> { + self.entry_by_pid + .get(&pid) + .map(|&index| &self.entries[index]) + } + + fn descendant_signatures(&self, root_pid: u32) -> Vec<ProcessSignature> { + let mut signatures = descendant_entries(root_pid, self) + .into_iter() + .map(ProcessSignature::from_entry) + .collect::<Vec<_>>(); + signatures.sort_unstable_by_key(|entry| entry.pid); + signatures + } + fn agent_indices(&self) -> &[usize] { self.agent_indices.get_or_init(|| { self.entries @@ -822,33 +953,30 @@ pub fn current_process_is_detached_server_daemon() -> bool { } pub fn foreground_job(child_pid: u32) -> Option<ForegroundJob> { - let snapshot = ProcessSnapshot::new(snapshot_processes()); - select_pane_foreground_job_from_snapshot(child_pid, &snapshot) + select_pane_foreground_job_cached(child_pid) } pub(crate) fn available_pane_shell(child_pid: u32) -> Option<String> { - available_pane_shell_from_snapshot(child_pid, &snapshot_processes()) + let snapshot = ProcessSnapshot::new(snapshot_processes()); + available_pane_shell_from_snapshot(child_pid, &snapshot) } fn available_pane_shell_from_snapshot( child_pid: u32, - entries: &[WindowsProcessEntry], + snapshot: &ProcessSnapshot, ) -> Option<String> { - let shell = entries.iter().find(|entry| entry.pid == child_pid)?; + let shell = snapshot.entry(child_pid)?; if !super::is_pane_shell_process_name(&shell.name) { return None; } - descendant_entries(child_pid, entries) + descendant_entries(child_pid, snapshot) .is_empty() .then(|| shell.name.clone()) } pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJob> { let snapshot = cached_foreground_processes(); - let entry = snapshot - .entries - .iter() - .find(|entry| entry.pid == process_group_id)?; + let entry = snapshot.entry(process_group_id)?; Some(ForegroundJob { process_group_id, processes: vec![foreground_process_from_entry(entry)], @@ -856,8 +984,7 @@ pub fn foreground_group_leader_job(process_group_id: u32) -> Option<ForegroundJo } pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> { - let snapshot = cached_foreground_processes(); - select_pane_foreground_job_from_snapshot(child_pid, &snapshot).map(|job| job.process_group_id) + select_pane_foreground_job_cached(child_pid).map(|job| job.process_group_id) } pub fn process_cwd(pid: u32) -> Option<PathBuf> { @@ -868,9 +995,43 @@ pub fn process_cwd(pid: u32) -> Option<PathBuf> { .filter(|path| path.is_absolute()) } +fn select_pane_foreground_job_cached(shell_pid: u32) -> Option<ForegroundJob> { + let snapshot = cached_foreground_processes(); + let (job, retry_with_fresh_snapshot) = + select_pane_foreground_job_from_snapshot(shell_pid, &snapshot)?; + if !retry_with_fresh_snapshot { + return Some(job); + } + + let snapshot = fresh_foreground_processes(); + select_pane_foreground_job_from_snapshot(shell_pid, &snapshot).map(|(job, _)| job) +} + fn select_pane_foreground_job_from_snapshot( shell_pid: u32, snapshot: &ProcessSnapshot, +) -> Option<(ForegroundJob, bool)> { + if let Some(job) = FOREGROUND_SELECTION_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()) + .get(shell_pid, snapshot) + { + return Some((job, false)); + } + + let job = select_pane_foreground_job_from_snapshot_uncached(shell_pid, snapshot)?; + let cached = prepare_cached_foreground_selection(shell_pid, snapshot, &job); + let retry_with_fresh_snapshot = job.process_group_id != shell_pid && cached.is_none(); + FOREGROUND_SELECTION_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()) + .remember(shell_pid, cached); + Some((job, retry_with_fresh_snapshot)) +} + +fn select_pane_foreground_job_from_snapshot_uncached( + shell_pid: u32, + snapshot: &ProcessSnapshot, ) -> Option<ForegroundJob> { select_pane_foreground_job_from_snapshot_with_runtime_inspection( shell_pid, @@ -887,8 +1048,8 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( 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 shell = snapshot.entry(shell_pid)?; + let descendants = descendant_entries(shell_pid, snapshot); let mut candidates = Vec::new(); for entry in std::iter::once(shell).chain(descendants) { if process_entry_identifies_agent(entry) { @@ -896,7 +1057,7 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( } } - if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, entries) { + if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, snapshot) { return Some(foreground_job_from_entry(selected)); } if !candidates.is_empty() || !shell_is_git_bash(shell) { @@ -918,7 +1079,7 @@ fn select_pane_foreground_job_from_snapshot_with_runtime_inspection( .filter(|entry| runtime_marker(entry).as_deref() == Some(shell_runtime_marker.as_str())) .collect(); let selected = - select_topmost_agent_chain_candidate(&matching_candidates, entries).unwrap_or(shell); + select_topmost_agent_chain_candidate(&matching_candidates, snapshot).unwrap_or(shell); Some(foreground_job_from_entry(selected)) } @@ -927,11 +1088,15 @@ 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())) + select_pane_foreground_job_from_snapshot_uncached( + 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() + crate::detect::identify_agent(&entry.name).is_some() + || crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() } fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob { @@ -943,32 +1108,24 @@ fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob { fn select_topmost_agent_chain_candidate<'a>( candidates: &[&'a WindowsProcessEntry], - entries: &[WindowsProcessEntry], + snapshot: &ProcessSnapshot, ) -> 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)) - .collect(); candidates.iter().copied().find(|entry| { candidates.iter().all(|other| { - entry.pid == other.pid || process_is_ancestor(entry.pid, other.pid, &parent_by_pid) + entry.pid == other.pid || process_is_ancestor(entry.pid, other.pid, snapshot) }) }) } -fn process_is_ancestor( - ancestor_pid: u32, - descendant_pid: u32, - parent_by_pid: &HashMap<u32, u32>, -) -> bool { +fn process_is_ancestor(ancestor_pid: u32, descendant_pid: u32, snapshot: &ProcessSnapshot) -> bool { let mut current = descendant_pid; let mut visited = HashSet::new(); while visited.insert(current) { - let Some(parent) = parent_by_pid.get(¤t).copied() else { + let Some(parent) = snapshot.entry(current).map(|entry| entry.parent_pid) else { return false; }; if parent == ancestor_pid { @@ -983,18 +1140,14 @@ fn process_is_ancestor( false } -fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&WindowsProcessEntry> { - let mut children: HashMap<u32, Vec<&WindowsProcessEntry>> = HashMap::new(); - for entry in entries { - children.entry(entry.parent_pid).or_default().push(entry); - } - +fn descendant_entries(root_pid: u32, snapshot: &ProcessSnapshot) -> Vec<&WindowsProcessEntry> { let mut output = Vec::new(); let mut queue = VecDeque::new(); let mut visited = HashSet::new(); visited.insert(root_pid); - if let Some(root_children) = children.get(&root_pid) { - for entry in root_children.iter().copied() { + if let Some(root_children) = snapshot.children_by_parent.get(&root_pid) { + for &index in root_children { + let entry = &snapshot.entries[index]; if visited.insert(entry.pid) { queue.push_back(entry); } @@ -1002,8 +1155,9 @@ fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&Wi } while let Some(entry) = queue.pop_front() { output.push(entry); - if let Some(next) = children.get(&entry.pid) { - for child in next.iter().copied() { + if let Some(next) = snapshot.children_by_parent.get(&entry.pid) { + for &index in next { + let child = &snapshot.entries[index]; if visited.insert(child.pid) { queue.push_back(child); } @@ -1014,12 +1168,13 @@ fn descendant_entries(root_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<&Wi } fn foreground_process_from_entry(entry: &WindowsProcessEntry) -> super::ForegroundProcess { + let command = entry.command(); super::ForegroundProcess { pid: entry.pid, name: entry.name.clone(), - argv0: entry.argv0.clone(), - argv: entry.argv.clone(), - cmdline: entry.cmdline.clone(), + argv0: command.argv0.clone(), + argv: command.argv.clone(), + cmdline: command.cmdline.clone(), } } @@ -1039,19 +1194,11 @@ fn snapshot_processes() -> Vec<WindowsProcessEntry> { while ok { let pid = entry.th32ProcessID; let name = nul_terminated_utf16_to_string(&entry.szExeFile); - let cmdline = process_command_line(pid); - let argv = cmdline.as_deref().and_then(command_line_to_argv); - let argv0 = argv - .as_ref() - .and_then(|argv| argv.first().cloned()) - .or_else(|| (!name.is_empty()).then(|| name.clone())); output.push(WindowsProcessEntry { pid, parent_pid: entry.th32ParentProcessID, name, - argv0, - argv, - cmdline, + command: OnceLock::new(), }); ok = unsafe { Process32NextW(snapshot, &mut entry) } != 0; } @@ -1065,6 +1212,170 @@ fn cached_foreground_processes() -> Arc<ProcessSnapshot> { cache.snapshot(FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL, snapshot_processes) } +fn fresh_foreground_processes() -> Arc<ProcessSnapshot> { + let mut cache = FOREGROUND_PROCESS_SNAPSHOT_CACHE + .lock() + .unwrap_or_else(|err| err.into_inner()); + cache.snapshot(Duration::ZERO, snapshot_processes) +} + +fn prepare_cached_foreground_selection( + shell_pid: u32, + snapshot: &ProcessSnapshot, + job: &ForegroundJob, +) -> Option<CachedForegroundSelection> { + if job.process_group_id == shell_pid { + return None; + } + let shell_identity = ProcessIdentity::open(shell_pid)?; + let selected_identity = ProcessIdentity::open(job.process_group_id)?; + let descendants = snapshot.descendant_signatures(shell_pid); + let descendant_identities = descendants + .iter() + .map(|entry| ProcessIdentity::open(entry.pid)) + .collect::<Option<Vec<_>>>()?; + CachedForegroundSelection::from_snapshot_with_identities( + shell_pid, + snapshot, + job, + descendants, + descendant_identities, + shell_identity, + selected_identity, + ) +} + +impl CachedForegroundSelection { + fn from_snapshot_with_identities( + shell_pid: u32, + snapshot: &ProcessSnapshot, + job: &ForegroundJob, + descendants: Vec<ProcessSignature>, + descendant_identities: Vec<ProcessIdentity>, + shell_identity: ProcessIdentity, + selected_identity: ProcessIdentity, + ) -> Option<Self> { + if job.process_group_id == shell_pid { + return None; + } + let shell_entry = snapshot.entry(shell_pid)?; + if shell_identity.creation_time() != shell_entry.command().creation_time { + return None; + } + let shell = ProcessSignature::from_entry(shell_entry); + let selected_entry = snapshot.entry(job.process_group_id)?; + if selected_identity.creation_time() != selected_entry.command().creation_time { + return None; + } + let selected = ProcessSignature::from_entry(selected_entry); + let descendants_match_identities = descendants.len() == descendant_identities.len() + && descendants + .iter() + .zip(&descendant_identities) + .all(|(signature, identity)| { + snapshot.entry(signature.pid).is_some_and(|entry| { + identity.creation_time() == entry.command().creation_time + }) + }); + if !descendants_match_identities + || !shell_identity.running() + || !selected_identity.running() + || !descendant_identities.iter().all(ProcessIdentity::running) + { + return None; + } + let now = Instant::now(); + Some(Self { + shell, + selected, + descendants, + descendant_identities, + shell_identity, + selected_identity, + job: job.clone(), + verified_at: now, + last_used: now, + }) + } +} + +impl ForegroundSelectionCache { + fn get(&mut self, shell_pid: u32, snapshot: &ProcessSnapshot) -> Option<ForegroundJob> { + if let Some(cached) = self.entries.get_mut(&shell_pid) { + let current_descendants = descendant_entries(shell_pid, snapshot); + let topology_matches = current_descendants.len() == cached.descendants.len() + && cached + .descendants + .iter() + .all(|entry| entry.matches(snapshot.entry(entry.pid))); + let valid = cached.verified_at.elapsed() < FOREGROUND_SELECTION_RECHECK + && cached.shell_identity.running() + && cached.selected_identity.running() + && cached + .descendant_identities + .iter() + .all(ProcessIdentity::running) + && cached.shell.matches(snapshot.entry(shell_pid)) + && cached + .selected + .matches(snapshot.entry(cached.job.process_group_id)) + && topology_matches; + if valid { + cached.last_used = Instant::now(); + return Some(cached.job.clone()); + } + } + self.entries.remove(&shell_pid); + None + } + + fn remember(&mut self, shell_pid: u32, cached: Option<CachedForegroundSelection>) { + let Some(cached) = cached else { + self.entries.remove(&shell_pid); + return; + }; + self.entries + .retain(|_, cached| cached.last_used.elapsed() < FOREGROUND_SELECTION_CACHE_RETENTION); + if self.entries.len() >= FOREGROUND_SELECTION_CACHE_CAPACITY { + self.entries.clear(); + } + self.entries.insert(shell_pid, cached); + } + + #[cfg(test)] + fn remember_for_test( + &mut self, + shell_pid: u32, + snapshot: &ProcessSnapshot, + job: &ForegroundJob, + ) { + let descendants = snapshot.descendant_signatures(shell_pid); + let descendant_identities = descendants + .iter() + .map(|_| ProcessIdentity::Stub { + running: true, + creation_time: None, + }) + .collect(); + let cached = CachedForegroundSelection::from_snapshot_with_identities( + shell_pid, + snapshot, + job, + descendants, + descendant_identities, + ProcessIdentity::Stub { + running: true, + creation_time: None, + }, + ProcessIdentity::Stub { + running: true, + creation_time: None, + }, + ); + self.remember(shell_pid, cached); + } +} + impl ProcessSnapshotCache { fn snapshot( &mut self, @@ -1086,10 +1397,34 @@ impl ProcessSnapshotCache { } } -fn process_command_line(pid: u32) -> Option<String> { - let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?; - let parameters = read_process_parameters(process.0)?; - read_unicode_string(process.0, parameters.command_line) +fn read_process_command(pid: u32, name: &str) -> WindowsProcessCommand { + let Some(process) = + ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ) + else { + let creation_time = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) + .and_then(|process| process_creation_time(process.0)); + return WindowsProcessCommand::from_cmdline(name, creation_time, None); + }; + let creation_time = process_creation_time(process.0); + let cmdline = read_process_parameters(process.0) + .and_then(|parameters| read_unicode_string(process.0, parameters.command_line)); + WindowsProcessCommand::from_cmdline(name, creation_time, cmdline) +} + +impl WindowsProcessCommand { + fn from_cmdline(name: &str, creation_time: Option<u64>, cmdline: Option<String>) -> Self { + let argv = cmdline.as_deref().and_then(command_line_to_argv); + let argv0 = argv + .as_ref() + .and_then(|argv| argv.first().cloned()) + .or_else(|| (!name.is_empty()).then(|| name.to_string())); + Self { + creation_time, + argv0, + argv, + cmdline, + } + } } fn process_is_git_bash(pid: u32) -> bool { @@ -1448,18 +1783,18 @@ pub fn session_processes(child_pid: u32) -> Vec<u32> { return Vec::new(); } - let entries = snapshot_processes(); - session_processes_from_entries(child_pid, &entries) + let snapshot = ProcessSnapshot::new(snapshot_processes()); + session_processes_from_snapshot(child_pid, &snapshot) } -fn session_processes_from_entries(child_pid: u32, entries: &[WindowsProcessEntry]) -> Vec<u32> { - if !entries.iter().any(|entry| entry.pid == child_pid) { +fn session_processes_from_snapshot(child_pid: u32, snapshot: &ProcessSnapshot) -> Vec<u32> { + if snapshot.entry(child_pid).is_none() { return Vec::new(); } let mut pids = vec![child_pid]; pids.extend( - descendant_entries(child_pid, entries) + descendant_entries(child_pid, snapshot) .into_iter() .map(|entry| entry.pid), ); @@ -2705,6 +3040,38 @@ mod tests { } #[test] + fn windows_process_tree_still_inspects_unusual_escaped_argv0() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]), + test_entry(20, 99, "launcher.exe", &["codex.exe"]), + ]); + + let job = super::select_pane_foreground_job_from_snapshot_with_runtime_inspection( + 10, + &snapshot, + |_| true, + |_| Some("pane-a".to_string()), + ) + .unwrap(); + + assert_eq!(job.process_group_id, 20); + assert_eq!(job.processes[0].name, "launcher.exe"); + } + + #[test] + fn windows_process_tree_still_inspects_unusual_descendant_argv0() { + let entries = vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "launcher.exe", &["codex.exe"]), + ]; + + let job = super::select_pane_foreground_job(10, &entries).unwrap(); + + assert_eq!(job.process_group_id, 20); + assert_eq!(job.processes[0].name, "launcher.exe"); + } + + #[test] 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"]), @@ -2907,6 +3274,207 @@ mod tests { } #[test] + fn windows_foreground_selection_cache_reuses_live_agent_and_invalidates_changes() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + ]); + let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &snapshot, &job); + + assert_eq!(cache.get(10, &snapshot), Some(job.clone())); + + cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub { + running: false, + creation_time: None, + }; + assert_eq!(cache.get(10, &snapshot), None); + + cache.remember_for_test(10, &snapshot, &job); + let overlap = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + test_entry(30, 10, "claude.exe", &["claude.exe"]), + ]); + assert_eq!(cache.get(10, &overlap), None); + + cache.remember_for_test(10, &snapshot, &job); + let changed = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "git.exe", &["git.exe"]), + ]); + assert_eq!(cache.get(10, &changed), None); + + cache.remember_for_test(10, &snapshot, &job); + cache.entries.get_mut(&10).unwrap().verified_at = Instant::now() + .checked_sub(super::FOREGROUND_SELECTION_RECHECK + Duration::from_secs(1)) + .unwrap(); + assert_eq!(cache.get(10, &snapshot), None); + } + + #[test] + fn windows_foreground_selection_cache_rejects_reused_descendant_pid() { + let original = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + test_entry(30, 10, "node.exe", &["node.exe", "worker.js"]), + ]); + let job = super::foreground_job_from_entry(original.entry(20).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &original, &job); + + let cached = cache.entries.get_mut(&10).unwrap(); + let reused_index = cached + .descendants + .iter() + .position(|entry| entry.pid == 30) + .unwrap(); + cached.descendant_identities[reused_index] = super::ProcessIdentity::Stub { + running: false, + creation_time: None, + }; + let replacement = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + test_entry(30, 10, "node.exe", &["node.exe", "codex.js"]), + ]); + + assert_eq!(cache.get(10, &replacement), None); + } + + #[test] + fn windows_foreground_selection_cache_rejects_identity_change_before_insertion() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry_with_creation_time(10, 1, "powershell.exe", &["powershell.exe"], Some(1)), + test_entry_with_creation_time(20, 10, "codex.exe", &["codex.exe"], Some(2)), + test_entry_with_creation_time(30, 10, "node.exe", &["node.exe", "worker.js"], Some(3)), + ]); + let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); + let descendants = snapshot.descendant_signatures(10); + let descendant_identities = vec![ + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(2), + }, + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(4), + }, + ]; + let mut cache = super::ForegroundSelectionCache::default(); + + let cached = super::CachedForegroundSelection::from_snapshot_with_identities( + 10, + &snapshot, + &job, + descendants, + descendant_identities, + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(1), + }, + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(2), + }, + ); + cache.remember(10, cached); + + assert!(cache.entries.is_empty()); + } + + #[test] + fn windows_foreground_selection_cache_accepts_limited_information_identity() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry_without_cmdline(10, 1, "powershell.exe", 1), + test_entry_without_cmdline(20, 10, "codex.exe", 2), + ]); + let job = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); + let cached = super::CachedForegroundSelection::from_snapshot_with_identities( + 10, + &snapshot, + &job, + snapshot.descendant_signatures(10), + vec![super::ProcessIdentity::Stub { + running: true, + creation_time: Some(2), + }], + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(1), + }, + super::ProcessIdentity::Stub { + running: true, + creation_time: Some(2), + }, + ); + + assert!(cached.is_some()); + assert_eq!(job.processes[0].argv0.as_deref(), Some("codex.exe")); + assert!(job.processes[0].cmdline.is_none()); + } + + #[test] + fn windows_foreground_selection_cache_prunes_unused_entries_on_insertion() { + let first_snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "powershell.exe", &["powershell.exe"]), + test_entry(20, 10, "codex.exe", &["codex.exe"]), + ]); + let first_job = super::foreground_job_from_entry(first_snapshot.entry(20).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + cache.remember_for_test(10, &first_snapshot, &first_job); + cache.entries.get_mut(&10).unwrap().last_used = Instant::now() + .checked_sub(super::FOREGROUND_SELECTION_CACHE_RETENTION + Duration::from_secs(1)) + .unwrap(); + + let second_snapshot = super::ProcessSnapshot::new(vec![ + test_entry(11, 1, "powershell.exe", &["powershell.exe"]), + test_entry(21, 11, "claude.exe", &["claude.exe"]), + ]); + let second_job = super::foreground_job_from_entry(second_snapshot.entry(21).unwrap()); + cache.remember_for_test(11, &second_snapshot, &second_job); + + assert!(!cache.entries.contains_key(&10)); + assert!(cache.entries.contains_key(&11)); + } + + #[test] + fn windows_foreground_selection_cache_does_not_retain_shell_result() { + let snapshot = super::ProcessSnapshot::new(vec![test_entry( + 10, + 1, + "powershell.exe", + &["powershell.exe"], + )]); + let shell = super::foreground_job_from_entry(snapshot.entry(10).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + + cache.remember_for_test(10, &snapshot, &shell); + + assert!(cache.entries.is_empty()); + } + + #[test] + fn windows_foreground_selection_cache_retains_live_escaped_agent() { + let snapshot = super::ProcessSnapshot::new(vec![ + test_entry(10, 1, "bash.exe", &["bash.exe"]), + test_entry(20, 99, "launcher.exe", &["codex.exe"]), + ]); + let escaped = super::foreground_job_from_entry(snapshot.entry(20).unwrap()); + let mut cache = super::ForegroundSelectionCache::default(); + + cache.remember_for_test(10, &snapshot, &escaped); + + assert_eq!(cache.get(10, &snapshot), Some(escaped.clone())); + cache.entries.get_mut(&10).unwrap().selected_identity = super::ProcessIdentity::Stub { + running: false, + creation_time: None, + }; + assert_eq!(cache.get(10, &snapshot), None); + } + + #[test] fn windows_process_tree_selects_wrapped_agent_descendant() { let entries = vec![ test_entry(10, 1, "cmd.exe", &["cmd.exe"]), @@ -3051,19 +3619,25 @@ mod tests { #[test] fn windows_shell_is_available_only_without_descendants() { - let shell_only = vec![test_entry(10, 1, "powershell.exe", &["powershell.exe"])]; + let shell_only = super::ProcessSnapshot::new(vec![test_entry( + 10, + 1, + "powershell.exe", + &["powershell.exe"], + )]); assert_eq!( super::available_pane_shell_from_snapshot(10, &shell_only).as_deref(), Some("powershell.exe") ); - let busy = vec![ + let busy = super::ProcessSnapshot::new(vec![ test_entry(10, 1, "powershell.exe", &["powershell.exe"]), test_entry(20, 10, "git.exe", &["git.exe", "status"]), - ]; + ]); assert_eq!(super::available_pane_shell_from_snapshot(10, &busy), None); - let replaced = vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]; + let replaced = + super::ProcessSnapshot::new(vec![test_entry(10, 1, "vim.exe", &["vim.exe"])]); assert_eq!( super::available_pane_shell_from_snapshot(10, &replaced), None @@ -3093,7 +3667,8 @@ mod tests { test_entry(40, 1, "unrelated.exe", &["unrelated.exe"]), ]; - let mut pids = super::session_processes_from_entries(10, &entries); + let snapshot = super::ProcessSnapshot::new(entries); + let mut pids = super::session_processes_from_snapshot(10, &snapshot); pids.sort_unstable(); assert_eq!(pids, vec![10, 20, 30]); @@ -3107,7 +3682,8 @@ mod tests { test_entry(30, 20, "node.exe", &["node.exe"]), ]; - let descendants = super::descendant_entries(10, &entries); + let snapshot = super::ProcessSnapshot::new(entries); + let descendants = super::descendant_entries(10, &snapshot); assert_eq!( descendants @@ -3164,13 +3740,52 @@ mod tests { name: &str, argv: &[&str], ) -> super::WindowsProcessEntry { + test_entry_with_creation_time(pid, parent_pid, name, argv, None) + } + + fn test_entry_without_cmdline( + pid: u32, + parent_pid: u32, + name: &str, + creation_time: u64, + ) -> super::WindowsProcessEntry { + let command = super::OnceLock::new(); + command + .set(super::WindowsProcessCommand::from_cmdline( + name, + Some(creation_time), + None, + )) + .unwrap(); + super::WindowsProcessEntry { + pid, + parent_pid, + name: name.to_string(), + command, + } + } + + fn test_entry_with_creation_time( + pid: u32, + parent_pid: u32, + name: &str, + argv: &[&str], + creation_time: Option<u64>, + ) -> super::WindowsProcessEntry { + let command = super::OnceLock::new(); + command + .set(super::WindowsProcessCommand { + creation_time, + argv0: argv.first().map(|value| (*value).to_string()), + argv: Some(argv.iter().map(|value| (*value).to_string()).collect()), + cmdline: Some(argv.join(" ")), + }) + .unwrap(); super::WindowsProcessEntry { pid, parent_pid, name: name.to_string(), - argv0: argv.first().map(|value| (*value).to_string()), - argv: Some(argv.iter().map(|value| (*value).to_string()).collect()), - cmdline: Some(argv.join(" ")), + command, } } |
