fix: preserve windows shell working directories

refs #4472
This commit is contained in:
Jonathan Liebig
2026-10-07 23:12:59 +02:00
parent 848075ff8c
commit 70e6da124d
3 changed files with 193 additions and 31 deletions
+2 -2
View File
@@ -3793,14 +3793,14 @@ impl PaneRuntime {
}
let pid = self.child_pid.load(Ordering::Relaxed);
crate::platform::process_cwd(pid)
crate::platform::pane_process_cwd(pid)
}
pub fn cwd_for_persistence(&self) -> Option<std::path::PathBuf> {
let pid = self.child_pid.load(Ordering::Acquire);
let exited = self.cwd_process_exited.load(Ordering::Acquire);
if let Some(cwd) = (!exited)
.then(|| crate::platform::process_cwd(pid))
.then(|| crate::platform::pane_process_cwd(pid))
.flatten()
.filter(|cwd| cwd.is_absolute())
{
+3
View File
@@ -414,6 +414,9 @@ mod windows;
#[cfg(target_os = "windows")]
pub use windows::*;
#[cfg(not(windows))]
pub(crate) use process_cwd as pane_process_cwd;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
mod fallback;
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
+188 -29
View File
@@ -446,6 +446,11 @@ pub(crate) fn set_default_plugin_pane_pwd(
) {
}
#[cfg(target_pointer_width = "64")]
use windows_sys::{
Wdk::System::Threading::ProcessWow64Information, Win32::System::Kernel::STRING32,
};
use windows_sys::{
Wdk::System::Threading::ProcessCommandLineInformation,
Wdk::System::Threading::{NtQueryInformationProcess, ProcessBasicInformation},
@@ -1580,12 +1585,80 @@ pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
pub fn process_cwd(pid: u32) -> Option<PathBuf> {
let process = ProcessHandle::open(pid, PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ)?;
let process_parameters = read_process_parameters(process.0)?;
read_unicode_string(process.0, process_parameters.current_directory.dos_path)
process_cwd_from_handle(process.0)
}
pub(crate) fn pane_process_cwd(child_pid: u32) -> Option<PathBuf> {
let process = ProcessHandle::open(
child_pid,
PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ,
)?;
let executable = PathBuf::from(process_executable_path(process.0)?);
if executable.with_extension("shim").is_file() {
// A Scoop shim keeps its launch cwd while the real shell can change directories.
// Never replace the saved shell directory with that launcher directory on exit.
let snapshot = cached_foreground_processes();
return process_cwd(shim_shell_pid(child_pid, &snapshot)?);
}
process_cwd_from_handle(process.0)
}
fn process_cwd_from_handle(process: HANDLE) -> Option<PathBuf> {
read_unicode_string(process, process_cwd_descriptor(process)?)
.map(PathBuf::from)
.filter(|path| path.is_absolute())
}
fn process_cwd_descriptor(process: HANDLE) -> Option<UNICODE_STRING> {
#[cfg(target_pointer_width = "64")]
{
let mut peb32_address = 0_usize;
// SAFETY: the output is a writable ULONG_PTR with its exact buffer size.
let status = unsafe {
NtQueryInformationProcess(
process,
ProcessWow64Information,
(&mut peb32_address as *mut usize).cast(),
size_of::<usize>() as u32,
null_mut(),
)
};
if status != STATUS_SUCCESS as NTSTATUS {
return None;
}
if peb32_address != 0 {
// WoW64's native PEB can report C:\Windows while the x86 shell uses
// another directory. Read its own pointer-width layout instead.
let peb = read_process_value::<Peb32>(process, peb32_address as *const c_void)?;
let parameters = read_process_value::<ProcessCwdParameters32>(
process,
peb.process_parameters as usize as *const c_void,
)?;
let cwd = parameters.current_directory;
return Some(UNICODE_STRING {
Length: cwd.Length,
MaximumLength: cwd.MaximumLength,
Buffer: cwd.Buffer as usize as *mut u16,
});
}
}
Some(read_process_parameters(process)?.current_directory.dos_path)
}
fn shim_shell_pid(child_pid: u32, snapshot: &ProcessSnapshot) -> Option<u32> {
snapshot.entry(child_pid)?;
let mut shells = snapshot
.children_by_parent
.get(&child_pid)?
.iter()
.filter_map(|&index| {
let entry = &snapshot.entries[index];
super::is_pane_shell_process_name(&entry.name).then_some(entry.pid)
});
let shell_pid = shells.next()?;
shells.next().is_none().then_some(shell_pid)
}
fn select_pane_foreground_job_cached(shell_pid: u32) -> Option<ForegroundJob> {
let snapshot = cached_foreground_processes();
let (job, retry_with_fresh_snapshot) =
@@ -2864,6 +2937,24 @@ struct Peb {
process_parameters: *mut RtlUserProcessParameters,
}
// Prefixes of the x86 PEB and RTL_USER_PROCESS_PARAMETERS through the fields
// needed for cwd; remote pointers must stay 32-bit on a 64-bit reader.
#[cfg(target_pointer_width = "64")]
#[repr(C)]
#[derive(Clone, Copy)]
struct Peb32 {
reserved: [u32; 4],
process_parameters: u32,
}
#[cfg(target_pointer_width = "64")]
#[repr(C)]
#[derive(Clone, Copy)]
struct ProcessCwdParameters32 {
reserved: [u32; 9],
current_directory: STRING32,
}
#[repr(C)]
#[derive(Clone, Copy)]
struct CurDir {
@@ -4022,40 +4113,108 @@ mod tests {
#[test]
fn windows_process_cwd_reads_normalized_child_launch_directory() {
use std::path::PathBuf;
let name = format!("Herdr-Cwd-Case-{}", std::process::id());
let cwd = std::env::temp_dir().join(&name);
fs::create_dir_all(&cwd).expect("create cwd fixture");
let cwd = super::normalize_cwd_for_launch_platform(&cwd);
let launch_cwd = cwd.with_file_name(name.to_ascii_lowercase());
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"])
.current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd))
.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_cwd(child.id());
if observed.as_ref().and_then(|path| path.file_name()) == Some(name.as_ref()) {
break;
}
thread::sleep(Duration::from_millis(100));
let changed = cwd.join("Changed");
fs::create_dir(&changed).expect("create changed cwd");
let windows = PathBuf::from(std::env::var_os("SystemRoot").expect("Windows SystemRoot"));
let shells = [
windows.join("System32").join("cmd.exe"),
#[cfg(target_pointer_width = "64")]
windows.join("SysWOW64").join("cmd.exe"),
];
let mut observations = Vec::new();
for shell in shells {
let mut child = Command::new(&shell)
.args(["/D", "/Q", "/K"])
.current_dir(super::normalize_cwd_for_launch_platform(&launch_cwd))
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.expect("spawn cmd");
let observe = |expected: &PathBuf| {
let deadline = Instant::now() + Duration::from_secs(5);
loop {
let observed = super::process_cwd(child.id());
if observed.as_ref() == Some(expected) || Instant::now() >= deadline {
break observed;
}
thread::sleep(Duration::from_millis(20));
}
};
let initial = observe(&cwd);
use std::io::Write;
writeln!(
child.stdin.as_ref().unwrap(),
"cd /d \"{}\"\r",
changed.display()
)
.expect("change cmd cwd");
let after_cd = observe(&changed);
let pane_cwd = super::pane_process_cwd(child.id());
let _ = child.kill();
let _ = child.wait();
observations.push((shell, initial, after_cd, pane_cwd));
}
let _ = child.kill();
let _ = child.wait();
let _ = fs::remove_dir_all(&cwd);
for (shell, initial, after_cd, pane_cwd) in observations {
assert_eq!(initial, Some(cwd.clone()), "{} launch cwd", shell.display());
assert_eq!(
after_cd,
Some(changed.clone()),
"{} live cd",
shell.display()
);
assert_eq!(pane_cwd, Some(changed.clone()), "ordinary shell owns cwd");
}
}
assert_eq!(
observed.as_ref().and_then(|path| path.file_name()),
Some(name.as_ref())
);
#[test]
fn windows_shim_cwd_uses_only_an_unambiguous_direct_shell() {
let cases = [
("cmdx.exe", vec![(11, 10, "cmd.exe")], Some(11)),
("pwsh.exe", vec![(11, 10, "pwsh.exe")], Some(11)),
(
"cmdx.exe",
vec![
(11, 10, "cmd.exe"),
(12, 11, "pwsh.exe"),
(13, 11, "node.exe"),
],
Some(11),
),
(
"cmdx.exe",
vec![(11, 10, "node.exe"), (12, 11, "cmd.exe")],
None,
),
("cmdx.exe", vec![], None),
(
"cmdx.exe",
vec![(11, 10, "cmd.exe"), (12, 10, "pwsh.exe")],
None,
),
];
for (root_name, children, expected) in cases {
let mut entries = vec![test_entry(10, 1, root_name, &[root_name])];
entries.extend(
children
.iter()
.map(|&(pid, parent, name)| test_entry(pid, parent, name, &[name])),
);
let snapshot = super::ProcessSnapshot::new(entries);
assert_eq!(
super::shim_shell_pid(10, &snapshot),
expected,
"{root_name}: {children:?}"
);
}
}
#[test]