From 2af45cbfb922cde1e75ff179fac02d88c84422d5 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 03:19:13 -0400 Subject: [PATCH] T29: the foreground is its group and the executable its leader runs, so exec herdr from a shell is followed and a program that did not read is tried again; a relative herdr socket lies in the client's folder; on Linux an executable an update replaced still names itself herdr --- AGENTS.md | 2 +- src/term/herdr.rs | 120 +++++++++++++++++++++++++++++++--------- src/term/herdr/tests.rs | 23 +++++++- tasks/acceptance.md | 2 +- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e689708..a24d20c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,4 +92,4 @@ 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). When that leader's 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`. 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. 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. Windows has none of this: there herdr is a beta on named pipes. +- 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. 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. Windows has none of this: there herdr is a beta on named pipes. diff --git a/src/term/herdr.rs b/src/term/herdr.rs index c33e01c..f5106fb 100644 --- a/src/term/herdr.rs +++ b/src/term/herdr.rs @@ -28,37 +28,60 @@ type Invocation = (Vec, HashMap); /// Follows the pane herdr's server has focused while herdr's client runs in the foreground. pub fn follow(cx: &mut Context) -> Task<()> { cx.spawn(async move |view, cx| { - let (mut group, mut socket, mut shown, mut failed) = (None, None, None, String::new()); + let mut seen: Option<(i32, Option)> = None; + let (mut socket, mut shown, mut retry, mut failed) = (None, None, false, String::new()); loop { cx.background_executor().timer(CHECK).await; - let Ok((pid, now)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground())) + let Ok((pid, group)) = view.update(cx, |view, _| (view.pty.pid, view.pty.foreground())) else { break; }; - let now = match now { - Ok(now) => Some(now), + let group = match group { + Ok(group) => Some(group), Err(error) => { - if group.is_some() { + if seen.is_some() { log::debug!("terminal {pid}: its foreground does not read: {error}"); } None } }; - if now != group { - group = now; + // An exec keeps the group and swaps the program, so the two name the foreground. + let program = match group { + Some(group) => { + let exe = cx + .background_executor() + .spawn(async move { path(group).ok() }) + .await; + Some((group, exe)) + } + None => None, + }; + if program != seen { + if let Some((group, None)) = &program { + log::debug!( + "terminal {pid}: process {group}, another user's or gone, does not read" + ); + } + seen = program.clone(); shown = None; + retry = true; failed.clear(); if socket.take().is_some() { log::info!("terminal {pid}: herdr left the foreground"); } - let found = match now { - Some(now) => { + } + if retry { + let found = match program { + Some((group, Some(exe))) + if exe.file_name().is_some_and(|name| name == "herdr") => + { cx.background_executor() - .spawn(async move { client(now) }) + .spawn(async move { client(group) }) .await } - None => Ok(None), + _ => Ok(None), }; + retry = found.is_err(); socket = found.unwrap_or_else(|error| { failure(&mut failed, pid, &error); None @@ -130,22 +153,20 @@ fn starting(error: &anyhow::Error) -> bool { }) } -/// The socket of the herdr server whose terminal client leads process group `group`, if one does. +/// The socket of the herdr server whose terminal client leads process group `group`, if it is one. fn client(group: i32) -> anyhow::Result> { - let path = match path(group) { - Ok(path) => path, - // Another user's program, such as sudo, or one already gone. - Err(error) => { - log::debug!("terminal: process {group} does not read: {error}"); - return Ok(None); - } - }; - if path.file_name().is_none_or(|name| name != "herdr") { - return Ok(None); - } let (args, env) = launch(group).with_context(|| format!("herdr: process {group} does not read"))?; - follows(&args, &env) + let Some(socket) = follows(&args, &env)? else { + return Ok(None); + }; + if socket.is_absolute() { + return Ok(Some(socket)); + } + // herdr reads a relative socket from its client's folder. + let folder = + cwd(group).with_context(|| format!("herdr: process {group}'s folder does not read"))?; + Ok(Some(folder.join(socket))) } /// The socket a herdr process with `args` and `env` shows, if it is herdr's terminal client. @@ -305,7 +326,17 @@ pub(super) fn focused(reply: &str) -> anyhow::Result> { #[cfg(target_os = "linux")] fn path(pid: i32) -> io::Result { - std::fs::read_link(format!("/proc/{pid}/exe")) + std::fs::read_link(format!("/proc/{pid}/exe")).map(installed) +} + +/// The path of an executable the kernel marks ` (deleted)`, as an update that replaced it leaves it. +#[cfg(target_os = "linux")] +pub(super) fn installed(path: PathBuf) -> PathBuf { + use std::os::unix::ffi::OsStrExt; + match path.as_os_str().as_bytes().strip_suffix(b" (deleted)") { + Some(kept) => PathBuf::from(OsStr::from_bytes(kept)), + None => path, + } } #[cfg(target_os = "linux")] @@ -317,6 +348,11 @@ fn launch(pid: i32) -> io::Result { Ok((args, env)) } +#[cfg(target_os = "linux")] +fn cwd(pid: i32) -> io::Result { + std::fs::read_link(format!("/proc/{pid}/cwd")) +} + /// Each NUL-ended string in `bytes`. #[cfg(target_os = "linux")] fn strings(bytes: &[u8]) -> impl Iterator { @@ -373,6 +409,40 @@ fn launch(pid: i32) -> io::Result { procargs(&buffer) } +#[cfg(target_os = "macos")] +fn cwd(pid: i32) -> io::Result { + // SAFETY: the struct is plain C data, valid zeroed. + let mut info: libc::proc_vnodepathinfo = unsafe { std::mem::zeroed() }; + let size = std::mem::size_of::() as libc::c_int; + // SAFETY: `info` holds the size given. + let read = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDVNODEPATHINFO, + 0, + (&raw mut info).cast(), + size, + ) + }; + if read <= 0 { + return Err(io::Error::last_os_error()); + } + if read != size { + return Err(io::Error::other(format!( + "proc_pidinfo read {read} of {size} bytes" + ))); + } + let path = info + .pvi_cdir + .vip_path + .iter() + .flatten() + .map(|byte| *byte as u8); + Ok(PathBuf::from(OsString::from_vec( + path.take_while(|byte| *byte != 0).collect(), + ))) +} + /// `KERN_PROCARGS2`'s layout: argc, the program's path, NUL padding, the arguments, the variables. #[cfg(target_os = "macos")] pub(super) fn procargs(buffer: &[u8]) -> io::Result { diff --git a/src/term/herdr/tests.rs b/src/term/herdr/tests.rs index 9fdf525..4580984 100644 --- a/src/term/herdr/tests.rs +++ b/src/term/herdr/tests.rs @@ -38,6 +38,8 @@ fn a_client_finds_its_server_as_herdr_does() { let socket = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "/s.sock")]); assert_eq!(found("herdr", &socket), PathBuf::from("/s.sock")); assert_eq!(found("herdr --session work", &socket), work); + let relative = env(&[("HOME", "/h"), ("HERDR_SOCKET_PATH", "herdr.sock")]); + assert_eq!(found("herdr", &relative), PathBuf::from("herdr.sock")); let named = env(&[("HOME", "/h"), ("HERDR_SESSION", "work")]); assert_eq!(found("herdr", &named), work); assert!(follows(&args("herdr"), &env(&[])).is_err()); @@ -120,7 +122,7 @@ fn herdr_answers_over_its_socket() { /// macOS hides the variables of its own programs, such as `/bin/sleep`, so the test reads itself. #[test] -fn a_process_reads_with_its_path_arguments_and_variables() { +fn a_process_reads_with_its_path_arguments_variables_and_folder() { let pid = std::process::id() as i32; let read = path(pid).expect("its path"); let (args, env) = launch(pid).expect("its arguments"); @@ -128,6 +130,25 @@ fn a_process_reads_with_its_path_arguments_and_variables() { assert_eq!(fs::canonicalize(read).ok(), fs::canonicalize(exe).ok()); assert_eq!(args, std::env::args().collect::>()); assert_eq!(env.get("HOME"), std::env::var_os("HOME").as_ref()); + let folder = std::env::current_dir().expect("this test's folder"); + assert_eq!( + fs::canonicalize(cwd(pid).expect("its folder")).ok(), + fs::canonicalize(folder).ok() + ); +} + +#[cfg(target_os = "linux")] +#[test] +fn an_updated_herdr_still_names_itself() { + let deleted = PathBuf::from("/home/u/.local/bin/herdr (deleted)"); + assert_eq!( + installed(deleted), + PathBuf::from("/home/u/.local/bin/herdr") + ); + assert_eq!( + installed("/usr/bin/herdr".into()), + PathBuf::from("/usr/bin/herdr") + ); } #[cfg(target_os = "macos")] diff --git a/tasks/acceptance.md b/tasks/acceptance.md index 011926c..1584d49 100644 --- a/tasks/acceptance.md +++ b/tasks/acceptance.md @@ -51,7 +51,7 @@ What no check reached: no one has looked at the window on Windows, which CI buil |---|---|---|---| | PTY through alacritty's event loop; OSC 7 scanned on the reader | T04 | `term::pty`, `term::tap` tests; round 3 failed at the cap, its fix stands on a red-green test | Pass | | Shell integration for zsh, bash, fish, PowerShell; a shell without it reports nothing and the panels say so | T05 | `term::shell` tests on macOS and Linux | Pass | -| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report; on macOS under `shot`, herdr 0.9.5 as the program, its pane's cd moved the panel | Pass | +| Inside herdr, the folder of herdr's focused pane, read from its socket, on macOS and Linux | T29 | `term::herdr` tests: the socket found as herdr 0.9 finds it, only the terminal client followed and a remote one refused, the snapshot read, a socket round trip, a process's path, arguments and variables; `the_foreground_is_the_program_s_own_group`; on X11 with herdr 0.9.5 run in the session: its start showed the focused pane's folder, a cd in the pane moved the panel, the focus moved to a pane in `/etc` and back moved it each time, and the client's end gave the panel back to bash's report, then `exec herdr` from that bash, with `HERDR_SOCKET_PATH` relative to its folder, was followed through the socket there; on macOS under `shot`, herdr 0.9.5 as the program and again by `exec` from `sh` with a relative socket, its pane's cd moved the panel | Pass | | Colors, styles, five underlines, wide characters, combining marks | T06 | `term::frame`, `term::ink`, `term::paint` tests | Pass | | Box drawing, blocks and powerline shapes as geometry | T07 | `term::boxes` tests | Pass | | JetBrains Mono, any installed font, Nerd Font icons | T06, T17 | `tests/fonts.rs` | Pass |