From 74613de659ed30e2578734c40e775c71e3076c2a Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Tue, 6 Oct 2026 22:38:09 +0200 Subject: [PATCH] feat(env): report a terminal's foreground job with ptyForeground The process group in front of the pty when it is not the program's own (a job its shell runs), with the leader's name on macOS and Linux. Exposed as RemoteTerminal.foreground(). --- packages/env/daemon/src/main.rs | 17 +++++--- packages/env/daemon/src/pty.rs | 62 +++++++++++++++++++++++++++++- packages/env/docs/protocol.md | 17 +++++--- packages/env/src/index.ts | 2 +- packages/env/src/terminal.ts | 17 ++++++++ packages/env/test/terminal.test.ts | 29 ++++++++++++++ 6 files changed, 130 insertions(+), 14 deletions(-) diff --git a/packages/env/daemon/src/main.rs b/packages/env/daemon/src/main.rs index 07ce957a6..4a9fb730a 100644 --- a/packages/env/daemon/src/main.rs +++ b/packages/env/daemon/src/main.rs @@ -400,12 +400,19 @@ fn serve_frames(server: &Arc, input: &mut impl Read) -> io::Result<()> { let jobs = start_workers(); while let Some(request) = frame::read_frame(input)? { match request.kind { - REQUEST if matches!(request.json["op"].as_str(), Some("ptyWrite" | "ptyResize")) => { + REQUEST + if matches!( + request.json["op"].as_str(), + Some("ptyWrite" | "ptyResize" | "ptyForeground") + ) => + { // Answered here, in arrival order: through the worker pool, two writes could swap. - let reply = if request.json["op"].as_str() == Some("ptyWrite") { - pty::write(&server.terminals, &request.json, request.payload) - } else { - pty::resize(&server.terminals, &request.json) + let reply = match request.json["op"].as_str() { + Some("ptyWrite") => { + pty::write(&server.terminals, &request.json, request.payload) + } + Some("ptyResize") => pty::resize(&server.terminals, &request.json), + _ => pty::foreground(&server.terminals, &request.json), }; server.output.control(match reply { Ok(json) => Frame::new(RESULT, request.id, json), diff --git a/packages/env/daemon/src/pty.rs b/packages/env/daemon/src/pty.rs index 70dcd32b7..00cc566aa 100644 --- a/packages/env/daemon/src/pty.rs +++ b/packages/env/daemon/src/pty.rs @@ -1,5 +1,6 @@ //! `pty`: a program in a pseudo-terminal, for interactive terminals. Unlike `exec`, output is the terminal's raw -//! bytes, and the program reads input: `ptyWrite` and `ptyResize` address the running `pty` request by its id. +//! bytes, and the program reads input: `ptyWrite`, `ptyResize` and `ptyForeground` address the running `pty` request by +//! its id. use crate::errors::Failure; use crate::exec::{Control, Groups}; @@ -19,11 +20,14 @@ const EXIT_GRACE: std::time::Duration = std::time::Duration::from_millis(100); /// Largest terminal size accepted, in cells. const MAX_CELLS: u64 = 4096; -/// What `ptyWrite` and `ptyResize` reach: the terminal of one running `pty` request. +/// What `ptyWrite`, `ptyResize` and `ptyForeground` reach: the terminal of one running `pty` request. pub struct Terminal { input: Sender>, #[cfg(unix)] master: Arc, + /// The program's process id, which is also its process group's (it leads the terminal's session). + #[cfg(unix)] + pid: u32, } /// Running terminals by the id of their `pty` request. @@ -116,6 +120,27 @@ pub fn resize(terminals: &Terminals, json: &Value) -> Result { Ok(json!({})) } +/// The process group in the terminal's foreground when it is not the program's own: a job a shell started, such as an +/// editor. `{ foreground: null }` while the program itself is in front (a shell at its prompt). +pub fn foreground(terminals: &Terminals, json: &Value) -> Result { + let terminal = find(terminals, json)?; + #[cfg(unix)] + { + let group = unix::foreground(&terminal.master) + .map_err(|error| Failure::io(&error, "tcgetpgrp", "pty"))?; + if group <= 0 || group as u32 == terminal.pid { + return Ok(json!({ "foreground": null })); + } + let name = unix::process_name(group); + Ok(json!({ "foreground": { "pid": group, "name": name } })) + } + #[cfg(not(unix))] + { + let _ = terminal; + Ok(json!({ "foreground": null })) + } +} + #[cfg(not(unix))] pub fn run( _id: u32, @@ -168,6 +193,7 @@ pub fn run( Arc::new(Terminal { input, master: master.clone(), + pid, }), ); output.control(Frame::new( @@ -323,6 +349,38 @@ mod unix { Ok(()) } + /// The terminal's foreground process group. + pub fn foreground(master: &File) -> io::Result { + // SAFETY: tcgetpgrp on a descriptor the terminal owns. + let group = unsafe { libc::tcgetpgrp(master.as_raw_fd()) }; + if group < 0 { + Err(io::Error::last_os_error()) + } else { + Ok(group) + } + } + + /// A process's name, if it still runs and the system tells. + #[cfg(target_vendor = "apple")] + pub fn process_name(pid: libc::pid_t) -> Option { + let mut buffer = [0u8; 256]; + // SAFETY: proc_name writes at most `buffer.len()` bytes and returns how many. + let length = + unsafe { libc::proc_name(pid, buffer.as_mut_ptr().cast(), buffer.len() as u32) }; + (length > 0).then(|| String::from_utf8_lossy(&buffer[..length as usize]).into_owned()) + } + + #[cfg(any(target_os = "linux", target_os = "android"))] + pub fn process_name(pid: libc::pid_t) -> Option { + let name = std::fs::read_to_string(format!("/proc/{pid}/comm")).ok()?; + Some(name.trim_end().to_string()).filter(|name| !name.is_empty()) + } + + #[cfg(not(any(target_vendor = "apple", target_os = "linux", target_os = "android")))] + pub fn process_name(_pid: libc::pid_t) -> Option { + None + } + fn cloexec(fd: libc::c_int) -> io::Result<()> { // SAFETY: fcntl on a descriptor this function's caller owns. unsafe { diff --git a/packages/env/docs/protocol.md b/packages/env/docs/protocol.md index c309cec98..e5d95c0bb 100644 --- a/packages/env/docs/protocol.md +++ b/packages/env/docs/protocol.md @@ -51,12 +51,12 @@ between chunks with `aborted`. `watch` stops and finishes with `{}`. Other opera ## Scheduling -File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite` -and `ptyResize` are answered in arrival order by the thread that reads frames, so keystrokes never swap. Chunk writes to one -handle run in arrival order. Results, errors, pings and `watch` events are sent before queued command output, and -results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not replies. Output of a -command without an output window waits while more than 4 MiB of output is unsent, which slows the command as a full -pipe would. +File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite`, +`ptyResize` and `ptyForeground` are answered in arrival order by the thread that reads frames, so keystrokes never +swap. Chunk writes to one handle run in arrival order. Results, errors, pings and `watch` events are sent before queued +command output, and results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not +replies. Output of a command without an output window waits while more than 4 MiB of output is unsent, which slows the +command as a full pipe would. ## Liveness @@ -127,3 +127,8 @@ While a `pty` request runs, `ptyWrite { target }` writes its payload to the term `ptyResize { target, cols, rows }` changes its size (the program gets `SIGWINCH`); `target` is the id of the `pty` request. Both return `{}` at once; input is queued and written in order, waiting while the program does not read it. Once the terminal has ended, they fail with `EBADF`. + +`ptyForeground { target }` reports the terminal's foreground process group when it is not the program's own: +`{ foreground: { pid, name } }`, where `pid` is the group's id and `name` its leader's process name (`null` if unknown), +or `{ foreground: null }` while the program itself is in front. For a shell this tells a running job (an editor, a +build) from the prompt; background jobs are not reported. It fails with `EBADF` once the terminal has ended. diff --git a/packages/env/src/index.ts b/packages/env/src/index.ts index cf08b0bc5..4c4b72026 100644 --- a/packages/env/src/index.ts +++ b/packages/env/src/index.ts @@ -26,5 +26,5 @@ export { sshArguments, sshConnection, } from "./ssh.ts"; -export { openTerminal, RemoteTerminal, type TerminalOptions } from "./terminal.ts"; +export { openTerminal, RemoteTerminal, type TerminalForeground, type TerminalOptions } from "./terminal.ts"; export { RemoteWatcher, type RemoteWatchOptions } from "./watch.ts"; diff --git a/packages/env/src/terminal.ts b/packages/env/src/terminal.ts index 77e7c8fe5..6a89abf21 100644 --- a/packages/env/src/terminal.ts +++ b/packages/env/src/terminal.ts @@ -17,6 +17,12 @@ export interface TerminalOptions { onData: (bytes: Uint8Array) => void; } +/** The foreground job of a terminal: its process group id, and its leader's name if the system tells. */ +export interface TerminalForeground { + pid: number; + name?: string; +} + /** * A program running in a pseudo-terminal of a pi-env daemon. It ends when the program exits, `close()` kills it, or * the connection is lost (the daemon then kills it). @@ -64,6 +70,17 @@ export class RemoteTerminal { this.#abort.abort(); } + /** + * The job in the terminal's foreground when it is not the program itself: what a shell runs, such as an editor. + * Undefined while the program is in front (a shell at its prompt). Rejects once the terminal has ended. + */ + async foreground(): Promise { + const reply = await this.#connection.request("ptyForeground", { target: this.#id }, { session: this.#session }); + const foreground = reply.json.foreground as { pid: number; name: string | null } | null; + if (!foreground) return undefined; + return { pid: foreground.pid, ...(foreground.name ? { name: foreground.name } : {}) }; + } + #request(op: string, json: Json, payload?: Uint8Array): void { this.#connection .request(op, { ...json, target: this.#id }, { session: this.#session, ...(payload ? { payload } : {}) }) diff --git a/packages/env/test/terminal.test.ts b/packages/env/test/terminal.test.ts index d329a3875..3c0ab1d8c 100644 --- a/packages/env/test/terminal.test.ts +++ b/packages/env/test/terminal.test.ts @@ -71,6 +71,35 @@ describe.skipIf(process.platform === "win32")("openTerminal", () => { expect(await terminal.exited).toBe(3); }, 20_000); + it("reports the job in the foreground, not the shell at its prompt", async () => { + const { connection, cwd } = connect(); + const output = collector(); + const terminal = await openTerminal(connection, { + cwd, + // Interactive, for job control: the shell puts each job in front of the terminal. + argv: ["/bin/sh", "-i"], + env: { PS1: "$ ", TERM: "xterm-256color" }, + cols: 80, + rows: 24, + onData: output.onData, + }); + terminal.write("echo READY\n"); + await output.waitFor("READY"); + expect(await terminal.foreground()).toBeUndefined(); + terminal.write("sleep 100\n"); + let foreground = await terminal.foreground(); + for (let tries = 0; !foreground && tries < 100; tries++) { + await new Promise((resolve) => setTimeout(resolve, 20)); + foreground = await terminal.foreground(); + } + expect(foreground?.pid).toBeGreaterThan(0); + expect(foreground?.pid).not.toBe(terminal.pid); + if (process.platform === "darwin" || process.platform === "linux") expect(foreground?.name).toBe("sleep"); + terminal.close(); + await expect(terminal.exited).rejects.toMatchObject({ code: "aborted" }); + await expect(terminal.foreground()).rejects.toMatchObject({ code: "EBADF" }); + }, 20_000); + it("delivers raw bytes, not decoded text", async () => { const { connection, cwd } = connect(); const output = collector();