fix(windows): detect agents across git bash exec boundaries (#2170)
refs #2107
This commit is contained in:
parent
885c9a6b5b
commit
df2cb2c3a5
|
|
@ -12,6 +12,7 @@
|
|||
- Relicensed Herdr from AGPL-3.0-or-later to Apache-2.0.
|
||||
|
||||
### Fixed
|
||||
- Windows agent detection now follows Git Bash-launched agents across emulated `exec` process boundaries. (#2107)
|
||||
- Detached Windows servers and pane processes now survive logout from the OpenSSH session that started them. (#2008)
|
||||
- Windows `agent start` now launches agents without native arguments instead of timing out on an invalid empty PowerShell argument list. (#2072)
|
||||
- Headless servers now resume restored agent sessions without waiting for a TUI client to attach. (#2064)
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fn apply_pane_launch_env(cmd: &mut CommandBuilder, launch_env: &PaneLaunchEnv) {
|
|||
}
|
||||
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
|
||||
crate::integration::apply_pane_base_env(cmd);
|
||||
crate::platform::apply_pane_runtime_marker(cmd);
|
||||
match &launch_env.identity {
|
||||
PaneLaunchIdentity::Inherit => {}
|
||||
PaneLaunchIdentity::Managed {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,13 @@ pub(crate) fn pane_custom_command_pty_builder(command: &str) -> portable_pty::Co
|
|||
pane_custom_command_pty_builder_platform(command)
|
||||
}
|
||||
|
||||
pub(crate) fn apply_pane_runtime_marker(command: &mut portable_pty::CommandBuilder) {
|
||||
apply_pane_runtime_marker_platform(command);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn apply_pane_runtime_marker_platform(_command: &mut portable_pty::CommandBuilder) {}
|
||||
|
||||
pub(crate) fn configure_background_command(command: &mut std::process::Command) {
|
||||
configure_background_command_platform(command);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,16 +5,19 @@ use std::{
|
|||
mem::{size_of, MaybeUninit},
|
||||
path::PathBuf,
|
||||
ptr::{copy_nonoverlapping, null_mut},
|
||||
sync::{Arc, Mutex},
|
||||
time::{Duration, Instant},
|
||||
sync::{
|
||||
atomic::{AtomicU64, Ordering as AtomicOrdering},
|
||||
Arc, LazyLock, Mutex,
|
||||
},
|
||||
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use windows_sys::{
|
||||
Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
|
||||
Win32::{
|
||||
Foundation::{
|
||||
CloseHandle, GlobalFree, LocalFree, HANDLE, HWND, INVALID_HANDLE_VALUE, NTSTATUS,
|
||||
STATUS_SUCCESS, UNICODE_STRING,
|
||||
CloseHandle, GlobalFree, LocalFree, FILETIME, HANDLE, HWND, INVALID_HANDLE_VALUE,
|
||||
NTSTATUS, STATUS_SUCCESS, UNICODE_STRING,
|
||||
},
|
||||
Globalization::{CompareStringOrdinal, CSTR_EQUAL, CSTR_GREATER_THAN, CSTR_LESS_THAN},
|
||||
System::{
|
||||
|
|
@ -31,11 +34,15 @@ use windows_sys::{
|
|||
IsProcessInJob, JobObjectExtendedLimitInformation, QueryInformationJobObject,
|
||||
JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
},
|
||||
Memory::{GlobalAlloc, GlobalLock, GlobalUnlock, GMEM_MOVEABLE},
|
||||
Memory::{
|
||||
GlobalAlloc, GlobalLock, GlobalUnlock, VirtualQueryEx, GMEM_MOVEABLE,
|
||||
MEMORY_BASIC_INFORMATION,
|
||||
},
|
||||
Ole::CF_UNICODETEXT,
|
||||
Threading::{
|
||||
GetCurrentProcess, GetExitCodeProcess, OpenProcess, TerminateProcess,
|
||||
CREATE_NO_WINDOW, DETACHED_PROCESS, PROCESS_BASIC_INFORMATION,
|
||||
GetCurrentProcess, GetExitCodeProcess, GetProcessTimes, OpenProcess,
|
||||
QueryFullProcessImageNameW, TerminateProcess, CREATE_NO_WINDOW, DETACHED_PROCESS,
|
||||
PROCESS_BASIC_INFORMATION, PROCESS_QUERY_INFORMATION,
|
||||
PROCESS_QUERY_LIMITED_INFORMATION, PROCESS_VM_READ,
|
||||
},
|
||||
},
|
||||
|
|
@ -63,6 +70,18 @@ use super::{ClipboardImage, ForegroundJob, Signal};
|
|||
|
||||
const STILL_ACTIVE: u32 = 259;
|
||||
const FOREGROUND_PROCESS_SNAPSHOT_CACHE_TTL: Duration = Duration::from_millis(250);
|
||||
const PANE_RUNTIME_MARKER_ENV_VAR: &str = "HERDR_PANE_RUNTIME_ID";
|
||||
const MAX_PROCESS_ENVIRONMENT_BYTES: usize = 256 * 1024;
|
||||
const PROCESS_ENVIRONMENT_READ_CHUNK_BYTES: usize = 16 * 1024;
|
||||
const PROCESS_RUNTIME_MARKER_CACHE_CAPACITY: usize = 1_024;
|
||||
const PROCESS_RUNTIME_MARKER_CACHE_RETENTION: Duration = Duration::from_secs(60);
|
||||
const PROCESS_RUNTIME_MARKER_NEGATIVE_TTL: Duration = Duration::from_secs(1);
|
||||
|
||||
static NEXT_PANE_RUNTIME_MARKER: AtomicU64 = AtomicU64::new(1);
|
||||
static PROCESS_RUNTIME_MARKER_CACHE: LazyLock<Mutex<HashMap<u32, CachedProcessRuntimeMarker>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
static GIT_BASH_PROCESS_CACHE: LazyLock<Mutex<HashMap<u32, CachedGitBashProcess>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Encode native or targeted semantic Win32 input for a compatible ConPTY destination.
|
||||
pub(crate) fn encode_windows_conpty_fallback(key: &crate::input::TerminalKey) -> Option<Vec<u8>> {
|
||||
|
|
@ -110,6 +129,21 @@ struct ProcessSnapshotCache {
|
|||
cached: Option<CachedProcessSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CachedProcessRuntimeMarker {
|
||||
creation_time: u64,
|
||||
marker: Option<String>,
|
||||
cached_at: Instant,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CachedGitBashProcess {
|
||||
creation_time: u64,
|
||||
is_git_bash: bool,
|
||||
last_used: Instant,
|
||||
}
|
||||
|
||||
static FOREGROUND_PROCESS_SNAPSHOT_CACHE: Mutex<ProcessSnapshotCache> =
|
||||
Mutex::new(ProcessSnapshotCache { cached: None });
|
||||
|
||||
|
|
@ -129,6 +163,21 @@ struct WindowsProcessEntry {
|
|||
|
||||
pub fn raise_server_nofile_limit() {}
|
||||
|
||||
pub(crate) fn apply_pane_runtime_marker_platform(command: &mut portable_pty::CommandBuilder) {
|
||||
if command_uses_git_bash(command) {
|
||||
command.env(PANE_RUNTIME_MARKER_ENV_VAR, next_pane_runtime_marker());
|
||||
}
|
||||
}
|
||||
|
||||
fn next_pane_runtime_marker() -> String {
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|duration| duration.as_nanos())
|
||||
.unwrap_or(0);
|
||||
let counter = NEXT_PANE_RUNTIME_MARKER.fetch_add(1, AtomicOrdering::Relaxed);
|
||||
format!("{:x}-{timestamp:x}-{counter:x}", std::process::id())
|
||||
}
|
||||
|
||||
fn raw_command_shell(comspec: Option<std::ffi::OsString>) -> std::ffi::OsString {
|
||||
comspec
|
||||
.filter(|value| !value.is_empty())
|
||||
|
|
@ -510,20 +559,62 @@ pub fn process_cwd(pid: u32) -> Option<PathBuf> {
|
|||
fn select_pane_foreground_job(
|
||||
shell_pid: u32,
|
||||
entries: &[WindowsProcessEntry],
|
||||
) -> Option<ForegroundJob> {
|
||||
select_pane_foreground_job_with_runtime_inspection(
|
||||
shell_pid,
|
||||
entries,
|
||||
|shell| process_is_git_bash(shell.pid),
|
||||
|entry| process_runtime_marker(entry.pid),
|
||||
)
|
||||
}
|
||||
|
||||
fn select_pane_foreground_job_with_runtime_inspection(
|
||||
shell_pid: u32,
|
||||
entries: &[WindowsProcessEntry],
|
||||
shell_is_git_bash: impl FnOnce(&WindowsProcessEntry) -> bool,
|
||||
mut runtime_marker: impl FnMut(&WindowsProcessEntry) -> Option<String>,
|
||||
) -> Option<ForegroundJob> {
|
||||
let shell = entries.iter().find(|entry| entry.pid == shell_pid)?;
|
||||
let descendants = descendant_entries(shell_pid, entries);
|
||||
let mut candidates = Vec::new();
|
||||
for entry in std::iter::once(shell).chain(descendants) {
|
||||
if crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some() {
|
||||
if process_entry_identifies_agent(entry) {
|
||||
candidates.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
let selected = select_topmost_agent_chain_candidate(&candidates, entries).unwrap_or(shell);
|
||||
if let Some(selected) = select_topmost_agent_chain_candidate(&candidates, entries) {
|
||||
return Some(foreground_job_from_entry(selected));
|
||||
}
|
||||
if !candidates.is_empty() || !shell_is_git_bash(shell) {
|
||||
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() {
|
||||
return Some(foreground_job_from_entry(shell));
|
||||
}
|
||||
|
||||
let Some(shell_runtime_marker) = runtime_marker(shell).filter(|marker| !marker.is_empty())
|
||||
else {
|
||||
return Some(foreground_job_from_entry(shell));
|
||||
};
|
||||
let matching_candidates: Vec<_> = escaped_candidates
|
||||
.into_iter()
|
||||
.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);
|
||||
Some(foreground_job_from_entry(selected))
|
||||
}
|
||||
|
||||
fn process_entry_identifies_agent(entry: &WindowsProcessEntry) -> bool {
|
||||
crate::detect::identify_agent_in_job(&foreground_job_from_entry(entry)).is_some()
|
||||
}
|
||||
|
||||
fn foreground_job_from_entry(entry: &WindowsProcessEntry) -> ForegroundJob {
|
||||
ForegroundJob {
|
||||
process_group_id: entry.pid,
|
||||
|
|
@ -679,6 +770,288 @@ fn process_command_line(pid: u32) -> Option<String> {
|
|||
read_unicode_string(process.0, parameters.command_line)
|
||||
}
|
||||
|
||||
fn process_is_git_bash(pid: u32) -> bool {
|
||||
let Some(process) = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION) else {
|
||||
return false;
|
||||
};
|
||||
let Some(creation_time) = process_creation_time(process.0) else {
|
||||
return false;
|
||||
};
|
||||
{
|
||||
let mut cache = GIT_BASH_PROCESS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if let Some(cached) = cache.get_mut(&pid) {
|
||||
if cached.creation_time == creation_time {
|
||||
cached.last_used = Instant::now();
|
||||
return cached.is_git_bash;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let is_git_bash = process_executable_path(process.0)
|
||||
.as_deref()
|
||||
.is_some_and(|path| is_git_bash_executable_path(std::path::Path::new(path)));
|
||||
let mut cache = GIT_BASH_PROCESS_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
||||
cache.retain(|_, cached| {
|
||||
cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION
|
||||
});
|
||||
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
cache.insert(
|
||||
pid,
|
||||
CachedGitBashProcess {
|
||||
creation_time,
|
||||
is_git_bash,
|
||||
last_used: Instant::now(),
|
||||
},
|
||||
);
|
||||
is_git_bash
|
||||
}
|
||||
|
||||
fn process_executable_path(process: HANDLE) -> Option<String> {
|
||||
let mut path = vec![0_u16; 32_768];
|
||||
let mut len = path.len() as u32;
|
||||
if unsafe { QueryFullProcessImageNameW(process, 0, path.as_mut_ptr(), &mut len) } == 0 {
|
||||
return None;
|
||||
}
|
||||
String::from_utf16(&path[..len as usize]).ok()
|
||||
}
|
||||
|
||||
fn command_uses_git_bash(command: &portable_pty::CommandBuilder) -> bool {
|
||||
let Some(program) = command.get_argv().first() else {
|
||||
return false;
|
||||
};
|
||||
let path = std::path::Path::new(program);
|
||||
if path.is_absolute() {
|
||||
return is_git_bash_executable_path(path);
|
||||
}
|
||||
if program.to_string_lossy().contains(['/', '\\']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
||||
return false;
|
||||
};
|
||||
let candidate_name = if file_name.eq_ignore_ascii_case("bash") {
|
||||
"bash.exe"
|
||||
} else if file_name.eq_ignore_ascii_case("bash.exe") {
|
||||
file_name
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let search_path = command
|
||||
.get_env("PATH")
|
||||
.map(OsStr::to_os_string)
|
||||
.or_else(|| std::env::var_os("PATH"));
|
||||
search_path.is_some_and(|search_path| {
|
||||
std::env::split_paths(&search_path)
|
||||
.map(|directory| directory.join(candidate_name))
|
||||
.find(|candidate| candidate.is_file())
|
||||
.is_some_and(|candidate| is_git_bash_executable_path(&candidate))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_git_bash_executable_path(path: &std::path::Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(OsStr::to_str) else {
|
||||
return false;
|
||||
};
|
||||
if !file_name.eq_ignore_ascii_case("bash.exe") || !path.is_absolute() || !path.is_file() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(bin_dir) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
if !bin_dir
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("bin"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(mut root) = bin_dir.parent() else {
|
||||
return false;
|
||||
};
|
||||
if root
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name.eq_ignore_ascii_case("usr"))
|
||||
{
|
||||
let Some(parent) = root.parent() else {
|
||||
return false;
|
||||
};
|
||||
root = parent;
|
||||
}
|
||||
|
||||
root.join("usr").join("bin").join("msys-2.0.dll").is_file()
|
||||
&& root.join("cmd").join("git.exe").is_file()
|
||||
}
|
||||
|
||||
fn process_runtime_marker(pid: u32) -> Option<String> {
|
||||
let process = ProcessHandle::open(pid, PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)?;
|
||||
let creation_time = process_creation_time(process.0)?;
|
||||
{
|
||||
let mut cache = PROCESS_RUNTIME_MARKER_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if let Some(cached) = cache.get_mut(&pid) {
|
||||
if cached.creation_time == creation_time
|
||||
&& (cached.marker.is_some()
|
||||
|| cached.cached_at.elapsed() < PROCESS_RUNTIME_MARKER_NEGATIVE_TTL)
|
||||
{
|
||||
cached.last_used = Instant::now();
|
||||
return cached.marker.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let marker = process_runtime_marker_from_handle(process.0)?;
|
||||
let mut cache = PROCESS_RUNTIME_MARKER_CACHE
|
||||
.lock()
|
||||
.unwrap_or_else(|err| err.into_inner());
|
||||
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
||||
cache.retain(|_, cached| {
|
||||
cached.last_used.elapsed() < PROCESS_RUNTIME_MARKER_CACHE_RETENTION
|
||||
});
|
||||
if cache.len() >= PROCESS_RUNTIME_MARKER_CACHE_CAPACITY {
|
||||
cache.clear();
|
||||
}
|
||||
}
|
||||
cache.insert(
|
||||
pid,
|
||||
CachedProcessRuntimeMarker {
|
||||
creation_time,
|
||||
marker: marker.clone(),
|
||||
cached_at: Instant::now(),
|
||||
last_used: Instant::now(),
|
||||
},
|
||||
);
|
||||
marker
|
||||
}
|
||||
|
||||
fn process_creation_time(process: HANDLE) -> Option<u64> {
|
||||
let mut creation_time = FILETIME::default();
|
||||
let mut exit_time = FILETIME::default();
|
||||
let mut kernel_time = FILETIME::default();
|
||||
let mut user_time = FILETIME::default();
|
||||
if unsafe {
|
||||
GetProcessTimes(
|
||||
process,
|
||||
&mut creation_time,
|
||||
&mut exit_time,
|
||||
&mut kernel_time,
|
||||
&mut user_time,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return None;
|
||||
}
|
||||
Some((u64::from(creation_time.dwHighDateTime) << 32) | u64::from(creation_time.dwLowDateTime))
|
||||
}
|
||||
|
||||
fn process_runtime_marker_from_handle(process: HANDLE) -> Option<Option<String>> {
|
||||
let parameters = read_process_parameters(process)?;
|
||||
let environment = read_process_environment(process, parameters.environment)?;
|
||||
Some(environment_variable_from_utf16(
|
||||
&environment,
|
||||
PANE_RUNTIME_MARKER_ENV_VAR,
|
||||
))
|
||||
}
|
||||
|
||||
fn read_process_environment(process: HANDLE, address: *const c_void) -> Option<Vec<u16>> {
|
||||
if address.is_null() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut memory = MaybeUninit::<MEMORY_BASIC_INFORMATION>::uninit();
|
||||
let queried = unsafe {
|
||||
VirtualQueryEx(
|
||||
process,
|
||||
address,
|
||||
memory.as_mut_ptr(),
|
||||
size_of::<MEMORY_BASIC_INFORMATION>(),
|
||||
)
|
||||
};
|
||||
if queried == 0 {
|
||||
return None;
|
||||
}
|
||||
let memory = unsafe { memory.assume_init() };
|
||||
let address = address as usize;
|
||||
let base = memory.BaseAddress as usize;
|
||||
let offset = address.checked_sub(base)?;
|
||||
let available = memory.RegionSize.checked_sub(offset)?;
|
||||
let read_len = available.min(MAX_PROCESS_ENVIRONMENT_BYTES);
|
||||
if read_len < size_of::<u16>() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let max_units = read_len / size_of::<u16>();
|
||||
let chunk_units = PROCESS_ENVIRONMENT_READ_CHUNK_BYTES / size_of::<u16>();
|
||||
let mut environment = Vec::new();
|
||||
while environment.len() < max_units {
|
||||
let unit_count = (max_units - environment.len()).min(chunk_units);
|
||||
let chunk_bytes = unit_count * size_of::<u16>();
|
||||
let mut chunk = vec![0_u16; unit_count];
|
||||
let mut bytes_read = 0;
|
||||
let offset = environment.len().checked_mul(size_of::<u16>())?;
|
||||
let chunk_address = address.checked_add(offset)?;
|
||||
if unsafe {
|
||||
ReadProcessMemory(
|
||||
process,
|
||||
chunk_address as *const c_void,
|
||||
chunk.as_mut_ptr().cast::<c_void>(),
|
||||
chunk_bytes,
|
||||
&mut bytes_read,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
chunk.truncate(bytes_read / size_of::<u16>());
|
||||
if chunk.is_empty() {
|
||||
break;
|
||||
}
|
||||
environment.extend_from_slice(&chunk);
|
||||
if let Some(end) = environment
|
||||
.windows(2)
|
||||
.position(|pair| pair == [0, 0])
|
||||
.map(|index| index + 2)
|
||||
{
|
||||
environment.truncate(end);
|
||||
return Some(environment);
|
||||
}
|
||||
if bytes_read < chunk_bytes {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn environment_variable_from_utf16(environment: &[u16], name: &str) -> Option<String> {
|
||||
for variable in environment.split(|unit| *unit == 0) {
|
||||
if variable.is_empty() {
|
||||
break;
|
||||
}
|
||||
let Some(separator) = variable.iter().position(|unit| *unit == u16::from(b'=')) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(variable_name) = String::from_utf16(&variable[..separator]) else {
|
||||
continue;
|
||||
};
|
||||
if variable_name.eq_ignore_ascii_case(name) {
|
||||
return String::from_utf16(&variable[separator + 1..]).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn read_process_parameters(process: HANDLE) -> Option<RtlUserProcessParameters> {
|
||||
let mut basic_info = MaybeUninit::<PROCESS_BASIC_INFORMATION>::uninit();
|
||||
let status = unsafe {
|
||||
|
|
@ -1049,6 +1422,7 @@ struct RtlUserProcessParameters {
|
|||
dll_path: UNICODE_STRING,
|
||||
image_path_name: UNICODE_STRING,
|
||||
command_line: UNICODE_STRING,
|
||||
environment: *mut c_void,
|
||||
}
|
||||
|
||||
fn read_process_value<T: Copy>(process: HANDLE, address: *const c_void) -> Option<T> {
|
||||
|
|
@ -1879,6 +2253,35 @@ mod tests {
|
|||
assert_eq!(observed.as_deref(), Some(cwd.as_path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_environment_reads_runtime_marker() {
|
||||
let shell =
|
||||
std::env::var_os("ComSpec").unwrap_or_else(|| r"C:\Windows\System32\cmd.exe".into());
|
||||
let mut child = Command::new(shell)
|
||||
.args(["/D", "/Q", "/C", "ping -n 11 127.0.0.1 > NUL"])
|
||||
.env(super::PANE_RUNTIME_MARKER_ENV_VAR, "pane-test")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn cmd");
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
let mut observed = None;
|
||||
while Instant::now() < deadline {
|
||||
observed = super::process_runtime_marker(child.id());
|
||||
if observed.as_deref() == Some("pane-test") {
|
||||
break;
|
||||
}
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
|
||||
assert_eq!(observed.as_deref(), Some("pane-test"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_selects_direct_agent_descendant() {
|
||||
let entries = vec![
|
||||
|
|
@ -1886,13 +2289,170 @@ mod tests {
|
|||
test_entry(20, 10, "codex.exe", &["codex.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job(10, &entries).unwrap();
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| panic!("Git Bash fallback must not run after normal detection succeeds"),
|
||||
|_| panic!("runtime marker must not be read after normal detection succeeds"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 20);
|
||||
assert_eq!(job.processes.len(), 1);
|
||||
assert_eq!(job.processes[0].name, "codex.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_recovers_git_bash_exec_chain_from_runtime_marker() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
||||
test_entry(
|
||||
11,
|
||||
10,
|
||||
"bash.exe",
|
||||
&[r"C:\Program Files\Git\usr\bin\bash.exe"],
|
||||
),
|
||||
test_entry(
|
||||
20,
|
||||
99,
|
||||
"sh.exe",
|
||||
&[r"C:\Program Files\Git\usr\bin\sh.exe", "/c/npm/codex"],
|
||||
),
|
||||
test_entry(
|
||||
30,
|
||||
20,
|
||||
"node.exe",
|
||||
&[
|
||||
r"C:\Program Files\nodejs\node.exe",
|
||||
r"C:\Users\user\AppData\Roaming\npm\node_modules\@openai\codex\bin\codex.js",
|
||||
],
|
||||
),
|
||||
test_entry(
|
||||
40,
|
||||
30,
|
||||
"codex.exe",
|
||||
&[r"C:\npm\node_modules\@openai\codex\bin\codex.exe"],
|
||||
),
|
||||
];
|
||||
let mut inspected = Vec::new();
|
||||
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| true,
|
||||
|entry| {
|
||||
inspected.push(entry.pid);
|
||||
Some("pane-a".to_string())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 20);
|
||||
assert_eq!(job.processes[0].name, "sh.exe");
|
||||
assert_eq!(inspected, vec![10, 20, 30, 40]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_skips_runtime_inspection_for_non_git_bash_shell() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "powershell.exe", &["powershell.exe"]),
|
||||
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| false,
|
||||
|_| panic!("runtime marker must not be read for non-Git-Bash panes"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_skips_runtime_inspection_without_agent_candidate() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
||||
test_entry(20, 99, "git.exe", &["git.exe", "status"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| true,
|
||||
|_| panic!("runtime marker must not be read without an agent candidate"),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_rejects_missing_or_empty_shell_runtime_marker() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
||||
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
||||
];
|
||||
|
||||
for shell_marker in [None, Some(String::new())] {
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| true,
|
||||
|entry| {
|
||||
if entry.pid == 10 {
|
||||
shell_marker.clone()
|
||||
} else {
|
||||
Some("pane-a".to_string())
|
||||
}
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_rejects_runtime_marker_from_another_pane() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
||||
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| true,
|
||||
|entry| Some(if entry.pid == 10 { "pane-a" } else { "pane-b" }.to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
assert_eq!(job.processes[0].name, "bash.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_process_tree_rejects_ambiguous_runtime_marker_candidates() {
|
||||
let entries = vec![
|
||||
test_entry(10, 1, "bash.exe", &[r"C:\Program Files\Git\bin\bash.exe"]),
|
||||
test_entry(20, 99, "codex.exe", &["codex.exe"]),
|
||||
test_entry(30, 98, "claude.exe", &["claude.exe"]),
|
||||
];
|
||||
|
||||
let job = super::select_pane_foreground_job_with_runtime_inspection(
|
||||
10,
|
||||
&entries,
|
||||
|_| true,
|
||||
|_| Some("pane-a".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(job.process_group_id, 10);
|
||||
assert_eq!(job.processes[0].name, "bash.exe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_foreground_process_snapshot_is_shared_within_ttl() {
|
||||
let mut cache = super::ProcessSnapshotCache { cached: None };
|
||||
|
|
@ -2189,6 +2749,62 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_environment_variable_parser_reads_case_insensitive_marker() {
|
||||
let environment: Vec<u16> = "PATH=C:\\Windows\0herdr_pane_runtime_id=pane-a\0\0"
|
||||
.encode_utf16()
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
super::environment_variable_from_utf16(
|
||||
&environment,
|
||||
super::PANE_RUNTIME_MARKER_ENV_VAR,
|
||||
)
|
||||
.as_deref(),
|
||||
Some("pane-a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_runtime_markers_are_distinct() {
|
||||
let first = super::next_pane_runtime_marker();
|
||||
let second = super::next_pane_runtime_marker();
|
||||
|
||||
assert_ne!(first, second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_runtime_marker_is_added_only_to_git_bash_environment() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"herdr-git-bash-test-{}",
|
||||
super::next_pane_runtime_marker()
|
||||
));
|
||||
fs::create_dir_all(root.join("bin")).expect("create Git Bash bin fixture");
|
||||
fs::create_dir_all(root.join("usr").join("bin")).expect("create Git Bash usr/bin fixture");
|
||||
fs::create_dir_all(root.join("cmd")).expect("create Git Bash cmd fixture");
|
||||
fs::write(root.join("bin").join("bash.exe"), []).expect("create Bash fixture");
|
||||
fs::write(root.join("usr").join("bin").join("msys-2.0.dll"), [])
|
||||
.expect("create MSYS runtime fixture");
|
||||
fs::write(root.join("cmd").join("git.exe"), []).expect("create Git fixture");
|
||||
|
||||
let mut git_bash = portable_pty::CommandBuilder::new(root.join("bin").join("bash.exe"));
|
||||
super::apply_pane_runtime_marker_platform(&mut git_bash);
|
||||
let mut path_resolved_git_bash = portable_pty::CommandBuilder::new("bash.exe");
|
||||
path_resolved_git_bash.env("PATH", root.join("bin"));
|
||||
super::apply_pane_runtime_marker_platform(&mut path_resolved_git_bash);
|
||||
let mut cmd = portable_pty::CommandBuilder::new("cmd.exe");
|
||||
super::apply_pane_runtime_marker_platform(&mut cmd);
|
||||
|
||||
assert!(git_bash
|
||||
.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR)
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
assert!(path_resolved_git_bash
|
||||
.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR)
|
||||
.is_some_and(|value| !value.is_empty()));
|
||||
assert!(cmd.get_env(super::PANE_RUNTIME_MARKER_ENV_VAR).is_none());
|
||||
fs::remove_dir_all(root).expect("remove Git Bash fixture");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ime_open_reflects_open_status() {
|
||||
// IMC_GETOPENSTATUS returns nonzero when the IME is open (Hangul
|
||||
|
|
|
|||
Loading…
Reference in New Issue