From 3b5bf46093f7ed760c3da1e5601271db600133a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 16 Oct 2026 16:38:45 -0400 Subject: [PATCH] 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 --- AGENTS.md | 2 +- src/term/herdr.rs | 21 +++-------- src/term/herdr/tests.rs | 62 +++++++++++++++++++++++++------ src/term/herdr/unix.rs | 20 ++++++++-- src/term/herdr/windows.rs | 78 +++++++++++++++++++++++---------------- 5 files changed, 120 insertions(+), 63 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f99c7aa..ae613e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -93,5 +93,5 @@ ELYTerminal. A terminal on GPUI and Ely components. `PRD.md` says what it does; - 2026-10-15: a release's macOS disk image comes from the Release workflow, dispatched once the draft holds the Windows and Linux builds. It runs on `macos-signing`, the Mac mini's runner in the owner's login session, whose keychain holds the Developer ID identity; at the owner's choice that runner moved from AgentCompany alone to the owner's level, so every repository reaches it. The job reads the App Store Connect key from the owner's user-level secrets `APPLE_API_ISSUER`, `APPLE_API_KEY_ID` and `APPLE_API_KEY_P8`, writes the .p8 into the runner's temp folder for notarytool alone, and puts the stapled image on the draft through `scripts/release-asset.sh` with the job's own token. One notarization covers the image and the app inside it; the image carries the ticket, and the app copied out finds its own online on its first open. - 2026-10-15: on Omarchy 4 (Linux) ELYTerminal follows the system's theme. Omarchy renders the current theme's `colors.toml` through its own resolver into `~/.local/state/omarchy/current/theme/ghostty.conf`, under `$HOME` whatever `XDG_STATE_HOME` says, so ELYTerminal reads that file and wears what Ghostty wears there: its color lines go through the strict parser the bundled themes use, and the rest, such as the glass block, pass by. Omarchy writes no `cursor-text`, so the background stands in, as in Ghostty and Omarchy's kitty template. ELYTerminal reads the file twice a second, since Omarchy signals only Alacritty, kitty and Ghostty, by name, and wears it whenever its text changes and parses; a check that fails is logged once while it lasts and tried again, so a file made readable again is worn though nothing else about it changed. The Omarchy theme is the default unless that file is surely absent, so a folder that cannot be read makes a fresh install wear it and the start name the error; another pick stops following. At start a missing file means no Omarchy unless the settings wear it, a file that cannot be read stops the start, and one that does not parse stops it only when worn, else it is logged and no Omarchy card shows. While running, a missing file is a switch in progress, and any other failure keeps the last theme. Omarchy 3's `~/.config/omarchy/current` gets no shim. - 2026-10-16: on Hyprland 0.56 a window blurs only while Hyprland's own `decoration:blur:enabled` is on: its `shouldBlur` reads that option first, then the window's `no_blur`, RGBX and opaque rules, then the blur region a surface sets through `ext_background_effect_v1`, which Hyprland offers with the blur capability whatever the option says. Omarchy 4.0.4's default `looknfeel.lua` turns the option off, so on Omarchy no window blurs until the user turns it on; ELYTerminal leaves the compositor's settings alone. On the owner's Omarchy the log named `ext_background_effect_v1` and the pane's region, and the wallpaper behind the window blurred with the option on and showed sharp with it off. -- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master) and what executable that group's leader runs: an exec keeps the group and swaps the executable, so `exec herdr` from a shell is a new program, and one that did not read is tried again; on Linux the ` (deleted)` mark an update leaves on a running executable is set aside. When that executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`; a relative socket lies in the client's folder, as herdr reads it (`/proc/PID/cwd`; `proc_pidinfo` on macOS). It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground; an answer that lands after the shell reported a folder is dropped, since the shell reports only once herdr has left. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. On Windows, which has no foreground group, the client is the `herdr.exe` nearest under the session's shell in a Toolhelp32 snapshot; `sysinfo` reads its arguments and variables, their names in upper case, as Windows matches them; its folder is found as herdr 0.9 finds it there, `XDG_CONFIG_HOME`, then `APPDATA`, then `USERPROFILE\AppData\Roaming`, then `HOME\.config`; and its server answers on the pipe herdr names `\\.\pipe\` and the socket's path as written, relative or not. A busy pipe is waited for, as herdr's client waits, and the pipe's server may identify ELYTerminal but never act as it. The pipe has no timeout: a herdr that never answers holds that session's follow until it answers or exits. +- 2026-10-16: herdr 0.9 keeps its panes' OSC 7 and writes only a title, notifications, OSC 52 and bells to the terminal it runs in, so no terminal hears a folder from inside it; herdr's server knows each pane's `cwd`, the folder its shell reported, else the shell's own. On macOS and Linux each session asks twice a second which process group leads its pty's foreground (`tcgetpgrp` on a copy of the master) and what executable that group's leader runs: an exec keeps the group and swaps the executable, so `exec herdr` from a shell is a new program, and one that did not read is tried again; on Linux the ` (deleted)` mark an update leaves on a running executable is set aside. When that executable is `herdr` and its arguments start herdr's terminal client (no command, `session attach NAME` or `client`), ELYTerminal reads the client's arguments and variables (`/proc` on Linux; `KERN_PROCARGS2` on macOS, which hides only the variables of Apple's own programs) and finds its socket as herdr 0.9 does: `--session NAME`, then `HERDR_SOCKET_PATH`, then `HERDR_SESSION`, under `$XDG_CONFIG_HOME/herdr`, else `~/.config/herdr`; a relative socket lies in the client's folder, as herdr reads it (`/proc/PID/cwd`; `proc_pidinfo` on macOS). It asks that socket for `session.snapshot` twice a second, a connection each, since herdr closes one after its reply, and reports the focused pane's `cwd` as the session's folder when it changes and again each time herdr takes the foreground; an answer that lands after the shell reported a folder is dropped, since the shell reports only once herdr has left. Focus belongs to herdr's server, so clients of one server follow one focus; a client of another machine's server (`--remote`, `--machine`) is logged, not followed. A failure is logged once while it lasts. On Windows, which has no foreground group, a Toolhelp32 snapshot lists the `herdr.exe` processes at or under the session's shell, none under another herdr, since what a herdr starts, its server too, is its own; when that list changes, `sysinfo` reads their arguments and variables, nearest first, names in upper case, as Windows matches them, and the first terminal client is followed, a server or a command beside it passed by; its folder is found as herdr 0.9 finds it there, `XDG_CONFIG_HOME`, then `APPDATA`, then `USERPROFILE\AppData\Roaming`, then `HOME\.config`; and its server answers on the pipe herdr names `\\.\pipe\` and the socket's path as written, relative or not. A busy pipe is waited for, as herdr's client waits, and the pipe's server may identify ELYTerminal but never act as it. The pipe has no timeout: a herdr that never answers holds that session's follow until it answers or exits. - 2026-10-16: the main window opens at the size it was left at, which the owner asked for on macOS, Windows and Linux: `window_width` and `window_height` in the settings hold the size gpui's `window_bounds` gives 0.5 s after the bounds settle, saved through `SettingsStore::update`, which writes only a change, so a drag saves once and a move saves nothing. A maximized, full-screen or hidden window keeps the size from before; its state and size are read once they settle, since X11 marks a window maximized after its new size, gpui's X11 window then gives a maximized window's own size, and it calls a maximized window unmaximized while minimized. A window that shows again, uncovered, restored or back on its screen, is read again, so a size it took just before it hid is kept; a size it takes in the half second before it closes, or before it hides until the app quits, is lost. The window is no smaller than 100 by 100, the least the settings keep, or a smaller size the platform allows could not be kept. The window opens centered at whole points, since macOS grows a window by a point on a side whose edge falls on a half point; its place is not kept, and a kept side past the primary display's visible area shrinks to it, logged. A size outside 100 to 16384 stops the start, as any value out of range does. diff --git a/src/term/herdr.rs b/src/term/herdr.rs index 89df3f3..2074608 100644 --- a/src/term/herdr.rs +++ b/src/term/herdr.rs @@ -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, HashMap); /// Follows the pane herdr's server has focused while herdr's client runs in the session. pub fn follow(cx: &mut Context) -> Task<()> { cx.spawn(async move |view, cx| { - let mut seen: Option<(Pid, Option)> = None; + let mut seen: Option = 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) -> 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) -> 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| { diff --git a/src/term/herdr/tests.rs b/src/term/herdr/tests.rs index 3de0aee..24d21a0 100644 --- a/src/term/herdr/tests.rs +++ b/src/term/herdr/tests.rs @@ -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 { Ok((args(words), data.clone())) }; + let gone = || -> io::Result { 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::>()); // 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)] diff --git a/src/term/herdr/unix.rs b/src/term/herdr/unix.rs index a294109..ed426d1 100644 --- a/src/term/herdr/unix.rs +++ b/src/term/herdr/unix.rs @@ -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); + /// 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 { 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)> { +pub(super) fn running(group: Pid) -> io::Result { 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> { +pub(super) fn client((group, exe): Program) -> anyhow::Result> { + 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 { diff --git a/src/term/herdr/windows.rs b/src/term/herdr/windows.rs index 1576d41..f7c1c1c 100644 --- a/src/term/herdr/windows.rs +++ b/src/term/herdr/windows.rs @@ -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; + /// 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 { 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)> { - 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 { + 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 { + 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> { } } -/// The socket of the herdr server whose terminal client is process `pid`, if it is one. -pub(super) fn client(pid: Pid) -> anyhow::Result> { - 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> { + 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)>, +) -> anyhow::Result> { + 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.