T32: on Windows herdr's client is sought among the herdr.exe processes at or under the session's shell, none under another herdr, whose server and panes are its own; they are read when that list changes, and a server or a command beside the client is passed by, where round 1 found the server taken for the client
CI / platforms (ubuntu-24.04) (push) Successful in 1m28s
CI / platforms (windows-latest) (push) Successful in 1m54s
CI / macos (push) Successful in 13m34s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-16 16:38:45 -04:00
1 parent d7dc1af6b9
commit 3b5bf46093
5 files changed
+120 -63

No files matched your search

+6 -15
View File
@@ -15,11 +15,11 @@ use super::view::{TerminalEvent, TerminalView};
#[cfg(unix)]
mod unix;
#[cfg(unix)]
use unix::{HERDR, Pid, client, connect, home, leader, running};
use unix::{Program, client, connect, home, leader, running};
#[cfg(windows)]
mod windows;
#[cfg(windows)]
use windows::{HERDR, Pid, client, connect, home, leader, running};
use windows::{Program, client, connect, home, leader, running};
/// How often the session's foreground program is checked.
const CHECK: Duration = Duration::from_millis(500);
@@ -36,7 +36,7 @@ type Invocation = (Vec<String>, HashMap<String, OsString>);
/// Follows the pane herdr's server has focused while herdr's client runs in the session.
pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
cx.spawn(async move |view, cx| {
let mut seen: Option<(Pid, Option<PathBuf>)> = None;
let mut seen: Option<Program> = None;
let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new());
loop {
cx.background_executor().timer(CHECK).await;
@@ -63,11 +63,6 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
}
};
if program != seen {
if let Some((process, None)) = &program {
log::debug!(
"terminal {pid}: process {process}, another user's or gone, does not read"
);
}
seen = program.clone();
shown = None;
retry = true;
@@ -78,16 +73,12 @@ pub fn follow(cx: &mut Context<TerminalView>) -> Task<()> {
}
if retry {
let found = match program {
Some((process, Some(exe)))
if exe
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case(HERDR)) =>
{
Some(program) => {
cx.background_executor()
.spawn(async move { client(process) })
.spawn(async move { client(program) })
.await
}
_ => Ok(None),
None => Ok(None),
};
retry = found.is_err();
socket = found.unwrap_or_else(|error| {
+50 -12
View File
@@ -147,6 +147,18 @@ fn a_process_reads_with_its_path_arguments_variables_and_folder() {
);
}
#[cfg(unix)]
#[test]
fn only_herdr_s_program_is_read() {
let pid = std::process::id() as i32;
assert_eq!(
client((pid, None)).expect("a program that does not read"),
None
);
let shell = client((pid, Some("/bin/sh".into())));
assert_eq!(shell.expect("another program"), None);
}
#[cfg(target_os = "linux")]
#[test]
fn an_updated_herdr_still_names_itself() {
@@ -198,38 +210,64 @@ fn a_windows_client_finds_its_server_in_app_data() {
#[cfg(windows)]
#[test]
fn herdr_is_sought_nearest_under_the_shell() {
use super::windows::nearest;
fn herdr_is_sought_at_and_under_the_shell_not_under_herdr() {
use super::windows::herdrs;
let processes = [
(100, 50, "pwsh.exe"),
(104, 100, "cmd.exe"),
(108, 104, "HERDR.EXE"),
(112, 100, "herdr.exe"),
(116, 112, "herdr.exe"),
(120, 100, "herdr.exe"),
(200, 300, "a.exe"),
(300, 200, "b.exe"),
]
.map(|(pid, parent, name)| (pid, parent, name.to_owned()));
let pid = |root| nearest(&processes, root, HERDR).map(|(pid, ..)| *pid);
assert_eq!(pid(100), Some(112));
assert_eq!(pid(104), Some(108));
assert_eq!(pid(112), None);
assert_eq!(pid(200), None);
// 116 is the server 112 started; 120 one started beside it.
assert_eq!(herdrs(&processes, 100), [112, 120, 108]);
assert_eq!(herdrs(&processes, 112), [112]);
assert!(herdrs(&processes, 200).is_empty());
}
#[cfg(windows)]
#[test]
fn a_server_or_a_command_beside_herdr_s_client_is_passed_by() {
use super::windows::pick;
let data = env(&[("APPDATA", r"C:\D")]);
let read = |words: &str| -> io::Result<Invocation> { Ok((args(words), data.clone())) };
let gone = || -> io::Result<Invocation> { Err(io::ErrorKind::NotFound.into()) };
let socket = Some(PathBuf::from(r"C:\D\herdr\herdr.sock"));
let server = "herdr.exe server";
let client = [(200, read(server)), (100, read("herdr.exe"))];
assert_eq!(pick(client).expect("a client"), socket);
let command = [(200, read(server)), (300, read("herdr.exe api snapshot"))];
assert_eq!(pick(command).expect("no client"), None);
let unread = [(300, gone()), (100, read("herdr.exe"))];
assert_eq!(pick(unread).expect("a client"), socket);
let error = pick([(300, gone()), (200, read(server))]).expect_err("an unread process");
assert!(format!("{error:#}").contains("process 300"), "{error:#}");
}
#[cfg(windows)]
#[test]
fn a_windows_process_reads_with_its_name_arguments_and_variables() {
use super::windows::launch;
use super::windows::{launch, processes};
let pid = std::process::id();
let exe = std::env::current_exe().expect("this test's path");
let name = exe.file_name().expect("this test's name");
let (process, read) = running(pid).expect("the process list");
assert_eq!(process, pid);
assert!(read.is_some_and(|read| read.as_os_str().eq_ignore_ascii_case(name)));
let listed = processes().expect("the process list");
assert!(
listed
.iter()
.any(|(id, _, read)| *id == pid && name.eq_ignore_ascii_case(read)),
"process {pid} is not listed under its name"
);
assert!(running(pid).expect("the process list").is_empty());
let (args, env) = launch(pid).expect("its arguments");
assert_eq!(args, std::env::args().collect::<Vec<_>>());
// Windows names it Path; the reader folds names to upper case.
assert_eq!(env.get("PATH"), std::env::var_os("PATH").as_ref());
let path = std::env::var_os("PATH");
assert!(env.get("PATH") == path.as_ref(), "PATH reads otherwise");
}
#[cfg(windows)]
+16 -4
View File
@@ -13,21 +13,33 @@ use crate::term::Pty;
pub(super) type Pid = i32;
/// The foreground's group and its leader's executable: an exec keeps the group and swaps the program.
pub(super) type Program = (Pid, Option<PathBuf>);
/// herdr's executable.
pub(super) const HERDR: &str = "herdr";
const HERDR: &str = "herdr";
/// The process group leading the session's foreground.
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
pty.foreground()
}
/// The group's leader and its executable: an exec keeps the group and swaps the program.
pub(super) fn running(group: Pid) -> io::Result<(Pid, Option<PathBuf>)> {
pub(super) fn running(group: Pid) -> io::Result<Program> {
Ok((group, path(group).ok()))
}
/// The socket of the herdr server whose terminal client leads process group `group`, if it is one.
pub(super) fn client(group: Pid) -> anyhow::Result<Option<PathBuf>> {
pub(super) fn client((group, exe): Program) -> anyhow::Result<Option<PathBuf>> {
let Some(exe) = exe else {
log::debug!("herdr: process {group}, another user's or gone, does not read");
return Ok(None);
};
if !exe
.file_name()
.is_some_and(|name| name.eq_ignore_ascii_case(HERDR))
{
return Ok(None);
}
let (args, env) =
launch(group).with_context(|| format!("herdr: process {group} does not read"))?;
let Some(socket) = follows(&args, &env)? else {
+47 -31
View File
@@ -30,46 +30,43 @@ use crate::term::Pty;
pub(super) type Pid = u32;
/// The herdr processes at or under the session's shell, nearest first.
pub(super) type Program = Vec<Pid>;
/// herdr's executable.
pub(super) const HERDR: &str = "herdr.exe";
const HERDR: &str = "herdr.exe";
/// The session's shell: Windows has no foreground group, so herdr is sought under it.
pub(super) fn leader(pty: &Pty) -> io::Result<Pid> {
Ok(pty.pid)
}
/// The herdr process nearest under `shell`, else `shell`; each with its executable's name.
pub(super) fn running(shell: Pid) -> io::Result<(Pid, Option<PathBuf>)> {
let processes = processes()?;
let found = nearest(&processes, shell, HERDR)
.or_else(|| processes.iter().find(|(pid, ..)| *pid == shell));
Ok(found.map_or((shell, None), |(pid, _, name)| {
(*pid, Some(PathBuf::from(name)))
}))
pub(super) fn running(shell: Pid) -> io::Result<Program> {
Ok(herdrs(&processes()?, shell))
}
/// The process nearest under `root` that runs `name`, breadth first.
pub(super) fn nearest<'a>(
processes: &'a [(Pid, Pid, String)],
root: Pid,
name: &str,
) -> Option<&'a (Pid, Pid, String)> {
let mut under = vec![root];
let mut at = 0;
while let Some(&parent) = under.get(at) {
/// The herdr processes at or under `root`, breadth first; what a herdr starts, its server among them, is its own.
pub(super) fn herdrs(processes: &[(Pid, Pid, String)], root: Pid) -> Vec<Pid> {
let herdr = |pid: Pid| {
processes
.iter()
.any(|(id, _, name)| *id == pid && name.eq_ignore_ascii_case(HERDR))
};
let (mut under, mut found, mut at) = (vec![root], Vec::new(), 0);
while let Some(&pid) = under.get(at) {
at += 1;
for process in processes.iter().filter(|(_, of, _)| *of == parent) {
if herdr(pid) {
found.push(pid);
continue;
}
for (child, ..) in processes.iter().filter(|(_, of, _)| *of == pid) {
// A parent id outlives its process, so a stale one may lead back up.
if under.contains(&process.0) {
continue;
if !under.contains(child) {
under.push(*child);
}
if process.2.eq_ignore_ascii_case(name) {
return Some(process);
}
under.push(process.0);
}
}
None
found
}
/// Every process: its id, its parent's, and its executable's name.
@@ -107,11 +104,30 @@ pub(super) fn processes() -> io::Result<Vec<(Pid, Pid, String)>> {
}
}
/// The socket of the herdr server whose terminal client is process `pid`, if it is one.
pub(super) fn client(pid: Pid) -> anyhow::Result<Option<PathBuf>> {
let (args, env) = launch(pid).with_context(|| format!("herdr: process {pid} does not read"))?;
// herdr names its pipe after the socket's path as written, relative or not.
follows(&args, &env)
/// The socket of the herdr server whose terminal client is among `herdrs`; a server or a command is passed by.
pub(super) fn client(herdrs: Program) -> anyhow::Result<Option<PathBuf>> {
pick(herdrs.into_iter().map(|pid| (pid, launch(pid))))
}
/// The first terminal client's socket in `reads`; with none, a failed read is the error, so it is tried again.
pub(super) fn pick(
reads: impl IntoIterator<Item = (Pid, io::Result<Invocation>)>,
) -> anyhow::Result<Option<PathBuf>> {
let mut unread = None;
for (pid, read) in reads {
match read.with_context(|| format!("herdr: process {pid} does not read")) {
// herdr names its pipe after the socket's path as written, relative or not.
Ok((args, env)) => {
if let Some(socket) = follows(&args, &env)? {
return Ok(Some(socket));
}
}
Err(error) => {
unread.get_or_insert(error);
}
}
}
unread.map_or(Ok(None), Err)
}
/// The arguments and variables of process `pid`; names fold to upper case, as Windows matches them.