aboutsummaryrefslogtreecommitdiff
path: root/src/platform
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-08-11 18:08:51 +0300
committerGitHub <noreply@github.com>2026-08-11 18:08:51 +0300
commit06ca0baa12f4203c5bbad9ecadf53f9a475a52b2 (patch)
treec97a018bb3aecd4c0f93d58a008fa7606f9dc3f2 /src/platform
parent6b0e6165afcc1aff6bea0c620ca2a812b8a35e13 (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/platform')
-rw-r--r--src/platform/windows.rs761
1 files changed, 688 insertions, 73 deletions
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(&current).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,
}
}