fix(windows): detect agents across git bash exec boundaries (#2170)

refs #2107
This commit is contained in:
Can Celik 2026-08-01 21:25:20 +03:00 committed by GitHub
parent 885c9a6b5b
commit df2cb2c3a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 635 additions and 10 deletions

View File

@ -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)

View File

@ -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 {

View File

@ -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);
}

View File

@ -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